From 1c73392f0186969db976783e4aadf46a02da842d Mon Sep 17 00:00:00 2001 From: Ludwig Schwardt Date: Wed, 1 Jul 2020 15:22:55 +0200 Subject: [PATCH 001/122] Clean up imports This removes a few unused imports, including a lost "import ephem" :-) Standardise the Astropy imports according to the Astropy examples, e.g. use u.rad instead of units.rad. Consolidate multiple imports from the same module where feasible. --- katpoint/antenna.py | 42 +++++++++++++------------- katpoint/bodies.py | 29 +++++++----------- katpoint/ephem_extra.py | 18 +++++------ katpoint/stars.py | 14 ++++----- katpoint/target.py | 37 ++++++++++------------- katpoint/test/test_body.py | 51 ++++++++++++++------------------ katpoint/test/test_catalogue.py | 7 ++--- katpoint/test/test_conversion.py | 8 ++--- katpoint/test/test_delay.py | 12 ++++---- katpoint/test/test_target.py | 6 ++-- katpoint/test/test_timestamp.py | 3 +- 11 files changed, 99 insertions(+), 128 deletions(-) diff --git a/katpoint/antenna.py b/katpoint/antenna.py index 2d2f912..d3ba3b5 100644 --- a/katpoint/antenna.py +++ b/katpoint/antenna.py @@ -25,10 +25,8 @@ from builtins import object import numpy as np -from astropy.coordinates import Latitude -from astropy.coordinates import Longitude -from astropy.coordinates import EarthLocation -from astropy import units +import astropy.units as u +from astropy.coordinates import Latitude, Longitude, EarthLocation from astropy.time import Time from .timestamp import Timestamp @@ -130,12 +128,12 @@ class Antenna(object): altitude in metres) earth_location :class:`astropy.coordinates.EarthLocation` object Underlying object used for pointing calculations - pressure :class:`astropy.units.Quantity + pressure :class:`astropy.units.Quantity` Atrmospheric pressure used to refraction calculations ref_earth_location :class:`astropy.coordinates.EarthLocation` object Array reference location for antenna in an array (same as *earth_location* for a stand-alone antenna) - ref_pressure :class:`astropy.units.Quantity + ref_pressure :class:`astropy.units.Quantity` Atrmospheric pressure used to refraction calculations Raises @@ -201,23 +199,23 @@ def __init__(self, name, latitude=None, longitude=None, altitude=None, # Set up reference earth location first if type(latitude) == str: - lat = Latitude(latitude, unit=units.deg) + lat = Latitude(latitude, unit=u.deg) else: - lat = Latitude(latitude, unit=units.rad) + lat = Latitude(latitude, unit=u.rad) if type(longitude) == str: - lon = Longitude(longitude, unit=units.deg) + lon = Longitude(longitude, unit=u.deg) else: - lon = Longitude(longitude, unit=units.rad) - if isinstance(altitude, units.Quantity): + lon = Longitude(longitude, unit=u.rad) + if isinstance(altitude, u.Quantity): height = altitude else: - height = float(altitude) * units.meter + height = float(altitude) * u.meter # Disable astropy's built-in refraction model. - self.ref_pressure = 0.0 * units.bar + self.ref_pressure = 0.0 * u.bar self.ref_earth_location = EarthLocation(lat=lat, lon=lon, height=height) - self.ref_position_wgs84 = self.ref_earth_location.lat.rad, self.ref_earth_location.lon.rad, self.ref_earth_location.height.to(units.meter).value + self.ref_position_wgs84 = self.ref_earth_location.lat.rad, self.ref_earth_location.lon.rad, self.ref_earth_location.height.to(u.meter).value if self.delay_model: dm = self.delay_model @@ -225,20 +223,20 @@ def __init__(self, name, latitude=None, longitude=None, altitude=None, # Convert ENU offset to ECEF coordinates of antenna, and then to WGS84 coordinates self.position_ecef = enu_to_ecef(self.ref_earth_location.lat.rad, self.ref_earth_location.lon.rad, - self.ref_earth_location.height.to(units.meter).value, + self.ref_earth_location.height.to(u.meter).value, *self.position_enu) lat, lon, elevation = ecef_to_lla(*self.position_ecef) - lat = Latitude(lat, unit=units.rad) - lon = Longitude(lon, unit=units.rad) + lat = Latitude(lat, unit=u.rad) + lon = Longitude(lon, unit=u.rad) self.pressure = 0.0 self.earth_location = EarthLocation(lat=lat, lon=lon, height=height) - self.position_wgs84 = self.earth_location.lat.rad, self.earth_location.lon.rad, self.earth_location.height.to(units.meter).value + self.position_wgs84 = self.earth_location.lat.rad, self.earth_location.lon.rad, self.earth_location.height.to(u.meter).value else: self.earth_location = self.ref_earth_location self.pressure = self.ref_pressure self.position_enu = (0.0, 0.0, 0.0) - self.position_wgs84 = lat, lon, alt = self.earth_location.lat.rad, self.earth_location.lon.rad, self.earth_location.height.to(units.meter).value + self.position_wgs84 = lat, lon, alt = self.earth_location.lat.rad, self.earth_location.lon.rad, self.earth_location.height.to(u.meter).value self.position_ecef = enu_to_ecef(lat, lon, alt, *self.position_enu) def __str__(self): @@ -280,11 +278,11 @@ def description(self): # These fields are used to build up the antenna description string fields = [self.name] location = self.ref_earth_location if self.delay_model else self.earth_location - fields += [location.lat.to_string(sep=':', unit=units.deg)] - fields += [location.lon.to_string(sep=':', unit=units.deg)] + fields += [location.lat.to_string(sep=':', unit=u.deg)] + fields += [location.lon.to_string(sep=':', unit=u.deg)] # State height to nearest micrometre (way overkill) to get rid of numerical fluff, # using poor man's {:.6g} that avoids scientific notation for very small heights - height_m = location.height.to(units.meter).value + height_m = location.height.to(u.meter).value fields += ['{:.6f}'.format(height_m).rstrip('0').rstrip('.')] fields += [str(self.diameter)] fields += [self.delay_model.description] diff --git a/katpoint/bodies.py b/katpoint/bodies.py index 4a28e16..b3a5352 100644 --- a/katpoint/bodies.py +++ b/katpoint/bodies.py @@ -27,19 +27,10 @@ import datetime import numpy as np -from astropy.coordinates import get_moon -from astropy.coordinates import get_body -from astropy.coordinates import get_sun -from astropy.coordinates import EarthLocation -from astropy.coordinates import solar_system_ephemeris -from astropy.coordinates import CIRS -from astropy.coordinates import ICRS -from astropy.coordinates import AltAz -from astropy.coordinates import SkyCoord -from astropy.time import Time -from astropy.time import TimeDelta -from astropy import coordinates -from astropy import units +import astropy.units as u +from astropy.coordinates import solar_system_ephemeris, get_body, get_sun, get_moon +from astropy.coordinates import CIRS, ICRS, SkyCoord, AltAz +from astropy.time import Time, TimeDelta import sgp4.model import sgp4.earth_gravity @@ -132,8 +123,8 @@ def writedb(self): See http://www.clearskyinstitute.com/xephem/xephem.html """ icrs = self._radec.transform_to(ICRS) - return '{},f,{},{}'.format(self.name, icrs.ra.to_string(sep=':', unit=units.hour), - icrs.dec.to_string(sep=':', unit=units.deg)) + return '{},f,{},{}'.format(self.name, icrs.ra.to_string(sep=':', unit=u.hour), + icrs.dec.to_string(sep=':', unit=u.deg)) class Sun(Body): @@ -268,9 +259,9 @@ def compute(self, loc, date, pressure): # Convert to alt, az at observer az, alt = get_observer_look(lon, lat, alt, utc_time, - loc.lon.deg, loc.lat.deg, loc.height.to(units.kilometer).value) + loc.lon.deg, loc.lat.deg, loc.height.to(u.kilometer).value) - self.altaz = SkyCoord(az*units.deg, alt*units.deg, location=loc, + self.altaz = SkyCoord(az*u.deg, alt*u.deg, location=loc, obstime=date, pressure=pressure, frame=AltAz) self.a_radec = self.altaz.transform_to(ICRS) @@ -464,8 +455,8 @@ def __init__(self, az, el, name=None): self._azel = AltAz(az=angle_from_degrees(az), alt=angle_from_degrees(el)) if not name: - name = "Az: {} El: {}".format(self._azel.az.to_string(sep=':', unit=units.deg), - self._azel.alt.to_string(sep=':', unit=units.deg)) + name = "Az: {} El: {}".format(self._azel.az.to_string(sep=':', unit=u.deg), + self._azel.alt.to_string(sep=':', unit=u.deg)) self.name = name def compute(self, loc, date, pressure): diff --git a/katpoint/ephem_extra.py b/katpoint/ephem_extra.py index a448f3c..9f30131 100644 --- a/katpoint/ephem_extra.py +++ b/katpoint/ephem_extra.py @@ -20,8 +20,8 @@ from past.builtins import basestring import numpy as np +import astropy.units as u from astropy.coordinates import Angle -from astropy import units # -------------------------------------------------------------------------------------------------- # --- Helper functions @@ -73,14 +73,14 @@ def angle_from_degrees(s): try: # Ephem expects a number or platform-appropriate string (i.e. Unicode on Py3) if type(s) == str: - return Angle(s, unit=units.deg) + return Angle(s, unit=u.deg) elif type(s) == tuple: - return Angle(s, unit=units.deg) + return Angle(s, unit=u.deg) else: - return Angle(s, unit=units.rad) + return Angle(s, unit=u.rad) except TypeError: # If input is neither, assume that it really wants to be a string - return Angle(_just_gimme_an_ascii_string(s), unit=units.deg) + return Angle(_just_gimme_an_ascii_string(s), unit=u.deg) def angle_from_hours(s): @@ -88,14 +88,14 @@ def angle_from_hours(s): try: # Ephem expects a number or platform-appropriate string (i.e. Unicode on Py3) if type(s) == str: - return Angle(s, unit=units.hour) + return Angle(s, unit=u.hour) elif type(s) == tuple: - return Angle(s, unit=units.hour) + return Angle(s, unit=u.hour) else: - return Angle(s, unit=units.rad) + return Angle(s, unit=u.rad) except TypeError: # If input is neither, assume that it really wants to be a string - return Angle(_just_gimme_an_ascii_string(s), unit=units.hour) + return Angle(_just_gimme_an_ascii_string(s), unit=u.hour) def wrap_angle(angle, period=2.0 * np.pi): diff --git a/katpoint/stars.py b/katpoint/stars.py index c502db1..f824822 100644 --- a/katpoint/stars.py +++ b/katpoint/stars.py @@ -29,15 +29,11 @@ import numpy as np +import astropy.units as u +from astropy.coordinates import SkyCoord, Longitude, Latitude, ICRS from astropy.time import Time -from astropy import units -from astropy.coordinates import SkyCoord -from astropy.coordinates import Longitude -from astropy.coordinates import Latitude -from astropy.coordinates import ICRS -from katpoint.bodies import FixedBody -from katpoint.bodies import EarthSatellite +from katpoint.bodies import FixedBody, EarthSatellite db = """\ @@ -178,8 +174,8 @@ def readdb(line): dec = fields[3].split('|')[0] s = FixedBody() s.name = name - ra = Longitude(ra, unit=units.hour) - dec = Latitude(dec, unit=units.deg) + ra = Longitude(ra, unit=u.hour) + dec = Latitude(dec, unit=u.deg) s._radec = SkyCoord(ra=ra, dec=dec, frame=ICRS) return s diff --git a/katpoint/target.py b/katpoint/target.py index 8fae0bf..753e4de 100644 --- a/katpoint/target.py +++ b/katpoint/target.py @@ -20,25 +20,20 @@ from past.builtins import basestring import numpy as np -from astropy.coordinates import ICRS -from astropy.coordinates import FK5 -from astropy.coordinates import FK4 -from astropy.coordinates import Galactic -from astropy.coordinates import Longitude -from astropy.coordinates import Latitude -from astropy.coordinates import SkyCoord -from astropy.coordinates import AltAz -from astropy import units + +import astropy.units as u +from astropy.coordinates import SkyCoord # High-level coordinates +from astropy.coordinates import ICRS, Galactic, FK4, FK5 # Low-level frames +from astropy.coordinates import Latitude, Longitude # Angles from astropy.time import Time -import ephem from .timestamp import Timestamp from .flux import FluxDensityModel -from .ephem_extra import (is_iterable, lightspeed, deg2rad, rad2deg, angle_from_degrees, angle_from_hours) +from .ephem_extra import (is_iterable, lightspeed, deg2rad, angle_from_degrees, angle_from_hours) from .conversion import azel_to_enu from .projection import sphere_to_plane, sphere_to_ortho, plane_to_sphere from . import bodies -from .bodies import FixedBody, readtle, Sun, StationaryBody, NullBody +from .bodies import FixedBody, readtle, StationaryBody, NullBody from .stars import star, readdb @@ -150,11 +145,11 @@ def __str__(self): descr += ' (%s)' % (', '.join(self.aliases),) descr += ', tags=%s' % (' '.join(self.tags),) if 'radec' in self.tags: - descr += ', %s %s' % (self.body._radec.ra.to_string(unit=units.hour), - self.body._radec.dec.to_string(unit=units.deg)) + descr += ', %s %s' % (self.body._radec.ra.to_string(unit=u.hour), + self.body._radec.dec.to_string(unit=u.deg)) if self.body_type == 'azel': - descr += ', %s %s' % (self.body._azel.az.to_string(unit=units.deg), - self.body._azel.alt.to_string(unit=units.deg)) + descr += ', %s %s' % (self.body._azel.az.to_string(unit=u.deg), + self.body._azel.alt.to_string(unit=u.deg)) if self.body_type == 'gal': gal = self.body._radec.transform_to(Galactic) descr += ', %.4f %.4f' % (gal.l.deg, gal.b.deg) @@ -247,8 +242,8 @@ def description(self): # Check if it's an unnamed target with a default name if names.startswith('Az:'): fields = [tags] - fields += [self.body._azel.az.to_string(unit=units.deg), - self.body._azel.alt.to_string(unit=units.deg)] + fields += [self.body._azel.az.to_string(unit=u.deg), + self.body._azel.alt.to_string(unit=u.deg)] if fluxinfo: fields += [fluxinfo] @@ -256,8 +251,8 @@ def description(self): # Check if it's an unnamed target with a default name if names.startswith('Ra:'): fields = [tags] - fields += [self.body._radec.dec.to_string(unit=units.hour), - self.body._radec.dec.to_string(unit=units.deg)] + fields += [self.body._radec.dec.to_string(unit=u.hour), + self.body._radec.dec.to_string(unit=u.deg)] if fluxinfo: fields += [fluxinfo] @@ -1076,7 +1071,7 @@ def construct_target_params(description): else: body.name = "Galactic l: %.4f b: %.4f" % (l, b) body._epoch = Time(2000.0, format='jyear') - body._radec = SkyCoord(l=Longitude(l, unit=units.deg), b=Latitude(b, unit=units.deg), frame=Galactic) + body._radec = SkyCoord(l=Longitude(l, unit=u.deg), b=Latitude(b, unit=u.deg), frame=Galactic) elif body_type == 'tle': lines = fields[-1].split('\n') diff --git a/katpoint/test/test_body.py b/katpoint/test/test_body.py index e6df1f2..a1cfe2e 100644 --- a/katpoint/test/test_body.py +++ b/katpoint/test/test_body.py @@ -6,35 +6,30 @@ """ import unittest + import numpy as np +import astropy.units as u +from astropy.coordinates import SkyCoord, ICRS, EarthLocation, Latitude, Longitude from astropy.time import Time -from astropy import coordinates -from astropy import units -from katpoint.bodies import FixedBody -from katpoint.bodies import Mars -from katpoint.bodies import Moon -from katpoint.bodies import Sun -from katpoint.bodies import readtle +from katpoint.bodies import FixedBody, Sun, Moon, Mars, readtle -from astropy.coordinates import SkyCoord -from astropy.coordinates import ICRS class TestFixedBody(unittest.TestCase): """Test for the FixedBody class.""" def test_compute(self): """Test compute method""" - lat = coordinates.Latitude('10:00:00.000', unit=units.deg) - lon = coordinates.Longitude('80:00:00.000', unit=units.deg) + lat = Latitude('10:00:00.000', unit=u.deg) + lon = Longitude('80:00:00.000', unit=u.deg) date = Time('2020-01-01 00:00:00.000') - ra = coordinates.Longitude('10:10:40.123', unit=units.hour) - dec = coordinates.Latitude('40:20:50.567', unit=units.deg) + ra = Longitude('10:10:40.123', unit=u.hour) + dec = Latitude('40:20:50.567', unit=u.deg) body = FixedBody() body._radec = SkyCoord(ra=ra, dec=dec, frame=ICRS) - body.compute(coordinates.EarthLocation(lat=lat, lon=lon, height=0.0), date, 0.0) + body.compute(EarthLocation(lat=lat, lon=lon, height=0.0), date, 0.0) - self.assertEqual(body.a_radec.ra.to_string(sep=':', unit=units.hour), + self.assertEqual(body.a_radec.ra.to_string(sep=':', unit=u.hour), '10:10:40.123') self.assertEqual(body.a_radec.dec.to_string(sep=':'), '40:20:50.567') @@ -43,36 +38,36 @@ def test_compute(self): self.assertEqual(body.altaz.alt.to_string(sep=':'), '51:21:20.0119') def test_planet(self): - lat = coordinates.Latitude('10:00:00.000', unit=units.deg) - lon = coordinates.Longitude('80:00:00.000', unit=units.deg) + lat = Latitude('10:00:00.000', unit=u.deg) + lon = Longitude('80:00:00.000', unit=u.deg) date = Time('2020-01-01 00:00:00.000') body = Mars() - body.compute(coordinates.EarthLocation(lat=lat, lon=lon, height=0.0), date, 0.0) + body.compute(EarthLocation(lat=lat, lon=lon, height=0.0), date, 0.0) # '118:10:06.1' '27:23:13.3' self.assertEqual(body.altaz.az.to_string(sep=':'), '118:10:05.1129') self.assertEqual(body.altaz.alt.to_string(sep=':'), '27:23:12.8499') def test_moon(self): - lat = coordinates.Latitude('10:00:00.000', unit=units.deg) - lon = coordinates.Longitude('80:00:00.000', unit=units.deg) + lat = Latitude('10:00:00.000', unit=u.deg) + lon = Longitude('80:00:00.000', unit=u.deg) date = Time('2020-01-01 10:00:00.000') body = Moon() - body.compute(coordinates.EarthLocation(lat=lat, lon=lon, height=0.0), date, 0.0) + body.compute(EarthLocation(lat=lat, lon=lon, height=0.0), date, 0.0) # 127:15:23.6 60:05:13.7' self.assertEqual(body.altaz.az.to_string(sep=':'), '127:15:17.1381') self.assertEqual(body.altaz.alt.to_string(sep=':'), '60:05:10.2438') def test_sun(self): - lat = coordinates.Latitude('10:00:00.000', unit=units.deg) - lon = coordinates.Longitude('80:00:00.000', unit=units.deg) + lat = Latitude('10:00:00.000', unit=u.deg) + lon = Longitude('80:00:00.000', unit=u.deg) date = Time('2020-01-01 10:00:00.000') body = Sun() - body.compute(coordinates.EarthLocation(lat=lat, lon=lon, height=0.0), date, 0.0) + body.compute(EarthLocation(lat=lat, lon=lon, height=0.0), date, 0.0) # 234:53:20.8 '31:38:09.4' self.assertEqual(body.altaz.az.to_string(sep=':'), '234:53:19.4835') @@ -138,14 +133,14 @@ def test_earth_satellite(self): self.assertEqual(rec.split(',')[10], xephem.split(',')[10]) # Test compute - lat = coordinates.Latitude('10:00:00.000', unit=units.deg) - lon = coordinates.Longitude('80:00:00.000', unit=units.deg) + lat = Latitude('10:00:00.000', unit=u.deg) + lon = Longitude('80:00:00.000', unit=u.deg) date = Time('2019-09-23 07:45:36.000') elevation = 4200.0 - sat.compute(coordinates.EarthLocation(lat=lat, lon=lon, height=elevation), date, 0.0) + sat.compute(EarthLocation(lat=lat, lon=lon, height=elevation), date, 0.0) # 3:32:59.21' '-2:04:36.3' - self.assertEqual(sat.a_radec.ra.to_string(sep=':', unit=units.hour), + self.assertEqual(sat.a_radec.ra.to_string(sep=':', unit=u.hour), '3:32:56.7813') self.assertEqual(sat.a_radec.dec.to_string(sep=':'), '-2:04:35.4329') diff --git a/katpoint/test/test_catalogue.py b/katpoint/test/test_catalogue.py index cc098a6..91056e2 100644 --- a/katpoint/test/test_catalogue.py +++ b/katpoint/test/test_catalogue.py @@ -20,8 +20,6 @@ import unittest import time -import ephem.stars - import katpoint @@ -93,7 +91,7 @@ def test_construct_catalogue(self): """Test construction of catalogues.""" cat = katpoint.Catalogue(add_specials=True, add_stars=True, antenna=self.antenna) num_targets_original = len(cat) - self.assertEqual(num_targets_original, len(katpoint.specials) + 1 + len(ephem.stars.stars), + self.assertEqual(num_targets_original, len(katpoint.specials) + 1 + len(katpoint.stars.stars), 'Number of targets incorrect') # Add target already in catalogue - no action cat.add(katpoint.Target('Sun, special')) @@ -174,11 +172,10 @@ def test_filter_catalogue(self): def test_sort_catalogue(self): """Test sorting of catalogues.""" cat = katpoint.Catalogue(add_specials=True, add_stars=True) - self.assertEqual(len(cat.targets), len(katpoint.specials) + 1 + len(ephem.stars.stars), + self.assertEqual(len(cat.targets), len(katpoint.specials) + 1 + len(katpoint.stars.stars), 'Number of targets incorrect') cat1 = cat.sort(key='name') self.assertEqual(cat1, cat, 'Catalogue equality failed') - # Ephem 3.7.7.0 added new stars self.assertIn(cat1.targets[0].name, {'Acamar', 'Achernar'}, 'Sorting on name failed') cat2 = cat.sort(key='ra', timestamp=self.timestamp, antenna=self.antenna) self.assertIn(cat2.targets[0].name, {'Alpheratz', 'Sirrah'}, 'Sorting on ra failed') diff --git a/katpoint/test/test_conversion.py b/katpoint/test/test_conversion.py index a09c923..6128301 100644 --- a/katpoint/test/test_conversion.py +++ b/katpoint/test/test_conversion.py @@ -20,8 +20,8 @@ import unittest import numpy as np -from astropy import coordinates -from astropy import units +import astropy.units as u +from astropy.coordinates import Angle import katpoint @@ -71,8 +71,8 @@ class TestSpherical(unittest.TestCase): """Closure tests for spherical coordinate transformations.""" def setUp(self): N = 1000 - self.az = coordinates.Angle(2.0 * np.pi * np.random.rand(N), unit=units.rad) - self.el = coordinates.Angle(0.999 * np.pi * (np.random.rand(N) - 0.5), unit=units.rad) + self.az = Angle(2.0 * np.pi * np.random.rand(N), unit=u.rad) + self.el = Angle(0.999 * np.pi * (np.random.rand(N) - 0.5), unit=u.rad) def test_azel_to_enu(self): """Closure tests for (az, el) to ENU conversion and vice versa.""" diff --git a/katpoint/test/test_delay.py b/katpoint/test/test_delay.py index f47d3c1..91a3410 100644 --- a/katpoint/test/test_delay.py +++ b/katpoint/test/test_delay.py @@ -25,8 +25,8 @@ from io import StringIO # python3 import numpy as np -from astropy import coordinates -from astropy import units +import astropy.units as u +from astropy.coordinates import Angle import katpoint @@ -119,8 +119,8 @@ def test_offset(self): azel = self.target1.azel(self.ts, self.ant1) offset = dict(projection_type='SIN') target3 = katpoint.construct_azel_target( - azel.az - coordinates.Angle(1.0, unit=units.deg), - azel.alt - coordinates.Angle(1.0, unit=units.deg)) + azel.az - Angle(1.0, unit=u.deg), + azel.alt - Angle(1.0, unit=u.deg)) x, y = target3.sphere_to_plane(azel.az.rad, azel.alt.rad, self.ts, self.ant1, **offset) offset['x'] = x offset['y'] = y @@ -138,8 +138,8 @@ def test_offset(self): # Now try (ra, dec) coordinate system radec = self.target1.radec(self.ts, self.ant1) offset = dict(projection_type='ARC', coord_system='radec') - target4 = katpoint.construct_radec_target(radec.ra - coordinates.Angle(1.0, unit=units.deg), - radec.dec - coordinates.Angle(1.0, unit=units.deg)) + target4 = katpoint.construct_radec_target(radec.ra - Angle(1.0, unit=u.deg), + radec.dec - Angle(1.0, unit=u.deg)) x, y = target4.sphere_to_plane(radec.ra.rad, radec.dec.rad, self.ts, self.ant1, **offset) offset['x'] = x offset['y'] = y diff --git a/katpoint/test/test_target.py b/katpoint/test/test_target.py index 9900aae..a798488 100644 --- a/katpoint/test/test_target.py +++ b/katpoint/test/test_target.py @@ -22,8 +22,8 @@ import pickle import numpy as np -from astropy import coordinates -from astropy import units +import astropy.units as u +from astropy.coordinates import Angle import katpoint @@ -266,7 +266,7 @@ def test_separation(self): np.testing.assert_almost_equal(sep, 0.0) sep = azel.separation(sun, self.ts, self.ant1) np.testing.assert_almost_equal(sep, 0.0) - azel2 = katpoint.construct_azel_target(azel_sun.az, azel_sun.alt + coordinates.Angle(0.01, unit=units.rad)) + azel2 = katpoint.construct_azel_target(azel_sun.az, azel_sun.alt + Angle(0.01, unit=u.rad)) sep = azel.separation(azel2, self.ts, self.ant1) np.testing.assert_almost_equal(sep, 0.01, decimal=7) diff --git a/katpoint/test/test_timestamp.py b/katpoint/test/test_timestamp.py index d56c2ca..725c3aa 100644 --- a/katpoint/test/test_timestamp.py +++ b/katpoint/test/test_timestamp.py @@ -18,9 +18,8 @@ from __future__ import print_function, division, absolute_import import unittest -from astropy.time import Time -import ephem +from astropy.time import Time import katpoint From d75d7b5c32cce70c50911462bac663228b9c013b Mon Sep 17 00:00:00 2001 From: Ludwig Schwardt Date: Wed, 1 Jul 2020 16:35:46 +0200 Subject: [PATCH 002/122] Flake8 cleanup Mostly indentation and whitespace issues, with the odd Python 3 conversion (psrcat_sources.py) thrown in. --- katpoint/__init__.py | 2 +- katpoint/antenna.py | 35 +++++++------- katpoint/bodies.py | 80 ++++++++++++++++++-------------- katpoint/catalogue.py | 12 +---- katpoint/conversion.py | 9 ---- katpoint/delay.py | 9 +--- katpoint/ephem_extra.py | 3 -- katpoint/flux.py | 2 - katpoint/model.py | 9 ++-- katpoint/pointing.py | 9 +--- katpoint/projection.py | 14 +----- katpoint/refraction.py | 6 +-- katpoint/stars.py | 7 +-- katpoint/target.py | 34 ++++---------- katpoint/test/__init__.py | 2 + katpoint/test/test_antenna.py | 5 +- katpoint/test/test_body.py | 28 +++++------ katpoint/test/test_catalogue.py | 2 + katpoint/test/test_conversion.py | 2 + katpoint/test/test_delay.py | 25 +++++----- katpoint/test/test_flux.py | 1 + katpoint/test/test_model.py | 1 + katpoint/test/test_pointing.py | 3 +- katpoint/test/test_projection.py | 6 +++ katpoint/test/test_refraction.py | 1 + katpoint/test/test_stars.py | 10 ++-- katpoint/test/test_target.py | 18 +++---- katpoint/test/test_timestamp.py | 3 +- katpoint/timestamp.py | 21 ++++----- scripts/psrcat_sources.py | 14 +++--- 30 files changed, 173 insertions(+), 200 deletions(-) diff --git a/katpoint/__init__.py b/katpoint/__init__.py index e11c376..7dd8a73 100644 --- a/katpoint/__init__.py +++ b/katpoint/__init__.py @@ -62,6 +62,7 @@ # Setup library logger and add a print-like handler used when no logging is configured class _NoConfigFilter(_logging.Filter): """Filter which only allows event if top-level logging is not configured.""" + def filter(self, record): return 1 if not _logging.root.handlers else 0 @@ -90,4 +91,3 @@ def filter(self, record): else: __version__ = _katversion.get_version(__path__[0]) # END VERSION CHECK - diff --git a/katpoint/antenna.py b/katpoint/antenna.py index d3ba3b5..2aae5a8 100644 --- a/katpoint/antenna.py +++ b/katpoint/antenna.py @@ -19,8 +19,8 @@ An *antenna* is considered to be a steerable parabolic dish containing multiple feeds. The :class:`Antenna` object wraps the antenna's location, dish diameter and other parameters that affect pointing and delay calculations. - """ + from __future__ import print_function, division, absolute_import from builtins import object @@ -157,8 +157,8 @@ class Antenna(object): internally. Generally the latitude and longitude should be specified up to 0.1 arcsecond precision, while altitude should be in metres and East, North and Up offsets are generally specified up to millimetres. - """ + def __init__(self, name, latitude=None, longitude=None, altitude=None, diameter=0.0, delay_model=None, pointing_model=None, beamwidth=1.22): @@ -212,38 +212,43 @@ def __init__(self, name, latitude=None, longitude=None, altitude=None, height = float(altitude) * u.meter # Disable astropy's built-in refraction model. self.ref_pressure = 0.0 * u.bar - self.ref_earth_location = EarthLocation(lat=lat, - lon=lon, height=height) + self.ref_earth_location = EarthLocation(lat=lat, lon=lon, height=height) - self.ref_position_wgs84 = self.ref_earth_location.lat.rad, self.ref_earth_location.lon.rad, self.ref_earth_location.height.to(u.meter).value + self.ref_position_wgs84 = (self.ref_earth_location.lat.rad, + self.ref_earth_location.lon.rad, + self.ref_earth_location.height.to(u.meter).value) if self.delay_model: dm = self.delay_model self.position_enu = (dm['POS_E'], dm['POS_N'], dm['POS_U']) # Convert ENU offset to ECEF coordinates of antenna, and then to WGS84 coordinates self.position_ecef = enu_to_ecef(self.ref_earth_location.lat.rad, - self.ref_earth_location.lon.rad, - self.ref_earth_location.height.to(u.meter).value, - *self.position_enu) + self.ref_earth_location.lon.rad, + self.ref_earth_location.height.to(u.meter).value, + *self.position_enu) lat, lon, elevation = ecef_to_lla(*self.position_ecef) - lat = Latitude(lat, unit=u.rad) + lat = Latitude(lat, unit=u.rad) lon = Longitude(lon, unit=u.rad) self.pressure = 0.0 - self.earth_location = EarthLocation(lat=lat, - lon=lon, height=height) - self.position_wgs84 = self.earth_location.lat.rad, self.earth_location.lon.rad, self.earth_location.height.to(u.meter).value + self.earth_location = EarthLocation(lat=lat, lon=lon, height=height) + self.position_wgs84 = (self.earth_location.lat.rad, + self.earth_location.lon.rad, + self.earth_location.height.to(u.meter).value) else: self.earth_location = self.ref_earth_location self.pressure = self.ref_pressure self.position_enu = (0.0, 0.0, 0.0) - self.position_wgs84 = lat, lon, alt = self.earth_location.lat.rad, self.earth_location.lon.rad, self.earth_location.height.to(u.meter).value + self.position_wgs84 = lat, lon, alt = (self.earth_location.lat.rad, + self.earth_location.lon.rad, + self.earth_location.height.to(u.meter).value) self.position_ecef = enu_to_ecef(lat, lon, alt, *self.position_enu) def __str__(self): """Verbose human-friendly string representation of antenna object.""" if np.any(self.position_enu): return "%s: %d-m dish at ENU offset %s m from lat %s, lon %s, alt %s m" % \ - tuple([self.name, self.diameter, np.array(self.position_enu)] + list(self.ref_position_wgs84)) + tuple([self.name, self.diameter, np.array(self.position_enu)] + + list(self.ref_position_wgs84)) else: return "%s: %d-m dish at lat %s, lon %s, alt %s m" % \ tuple([self.name, self.diameter] + list(self.position_wgs84)) @@ -310,7 +315,6 @@ def baseline_toward(self, antenna2): ------- e_m, n_m, u_m : float or array East, North, Up coordinates of baseline vector, in metres - """ # If this antenna is at reference position of second antenna, simply return its ENU offset if self.position_wgs84 == antenna2.ref_position_wgs84: @@ -334,7 +338,6 @@ def local_sidereal_time(self, timestamp=None): ------- lst : :class:`astropy.coordinates.Angle` object, or sequence of objects Local sidereal time(s), in radians - """ def _scalar_local_sidereal_time(t): """Calculate local sidereal time at a single time instant.""" diff --git a/katpoint/bodies.py b/katpoint/bodies.py index b3a5352..77867c4 100644 --- a/katpoint/bodies.py +++ b/katpoint/bodies.py @@ -41,6 +41,7 @@ from .ephem_extra import angle_from_degrees + class Body(object): """Base class for all Body classes. @@ -56,6 +57,7 @@ class Body(object): altaz : astropy.coordinates.AltAz Topocentric alt/az of body at date of observation """ + def __init__(self): self._epoch = Time(2000.0, format='jyear') @@ -84,8 +86,7 @@ def _compute(self, loc, date, pressure, icrs_radec): self.radec = self.a_radec.transform_to(CIRS(obstime=date)) # ICRS to Az/El - self.altaz = self.radec.transform_to(AltAz(location=loc, - obstime=date, pressure=pressure)) + self.altaz = self.radec.transform_to(AltAz(location=loc, obstime=date, pressure=pressure)) class FixedBody(Body): @@ -97,6 +98,7 @@ class FixedBody(Body): _radec : astropy.coordinates.SkyCoord Position of body in some RA/Dec frame """ + def __init__(self): Body.__init__(self) @@ -148,6 +150,7 @@ def compute(self, loc, date, pressure): icrs = moon.transform_to(ICRS) Body._compute(self, loc, date, pressure, icrs) + class Earth(Body): def __init__(self): Body.__init__(self) @@ -155,6 +158,7 @@ def __init__(self): def compute(self): pass + class Planet(Body): def __init__(self, name): Body.__init__(self) @@ -166,43 +170,52 @@ def compute(self, loc, date, pressure): icrs = planet.transform_to(ICRS) Body._compute(self, loc, date, pressure, icrs) + class Mercury(Planet): def __init__(self): Planet.__init__(self, 'mercury') self.name = 'Mercury' + class Venus(Planet): def __init__(self): Planet.__init__(self, 'venus') self.name = 'Venus' + class Mars(Planet): def __init__(self): Planet.__init__(self, 'mars') self.name = 'Mars' + class Jupiter(Planet): def __init__(self): Planet.__init__(self, 'jupiter') self.name = 'Jupiter' + class Saturn(Planet): def __init__(self): Planet.__init__(self, 'saturn') self.name = 'Saturn' + class Uranus(Planet): def __init__(self): Planet.__init__(self, 'uranus') self.name = 'Uranus' + class Neptune(Planet): def __init__(self): Planet.__init__(self, 'neptune') self.name = 'Neptune' + class EarthSatellite(Body): """Body orbiting the earth.""" + def __init__(self): Body.__init__(self) @@ -233,7 +246,7 @@ def compute(self, loc, date, pressure): self._sat.ecco = self._e self._sat.argpo = self._ap self._sat.mo = self._M - self._sat.no_kozai = self._n / (24.0 *60.0) * (2.0 * np.pi) + self._sat.no_kozai = self._n / (24.0 * 60.0) * (2.0 * np.pi) # Compute position and velocity date = date.iso @@ -244,25 +257,24 @@ def compute(self, loc, date, pressure): m = int(date[14:16]) s = float(date[17:]) sgp4init(sgp4.earth_gravity.wgs84, False, self._sat.satnum, - self._sat.jdsatepoch-2433281.5, self._sat.bstar, - self._sat.ndot, self._sat.nddot, - self._sat.ecco, self._sat.argpo, self._sat.inclo, - self._sat.mo, self._sat.no, - self._sat.nodeo, self._sat) + self._sat.jdsatepoch-2433281.5, self._sat.bstar, + self._sat.ndot, self._sat.nddot, + self._sat.ecco, self._sat.argpo, self._sat.inclo, + self._sat.mo, self._sat.no, + self._sat.nodeo, self._sat) p, v = self._sat.propagate(yr, mon, day, h, m, s) # Convert to lon/lat/alt - utc_time = datetime.datetime(yr, mon, day, h, m, int(s), - int(s - int(s)) * 1000000) + utc_time = datetime.datetime(yr, mon, day, h, m, int(s), int(s - int(s)) * 1000000) pos = np.array(p) lon, lat, alt = pyorbital.geoloc.get_lonlatalt(pos, utc_time) # Convert to alt, az at observer az, alt = get_observer_look(lon, lat, alt, utc_time, - loc.lon.deg, loc.lat.deg, loc.height.to(u.kilometer).value) + loc.lon.deg, loc.lat.deg, loc.height.to(u.kilometer).value) self.altaz = SkyCoord(az*u.deg, alt*u.deg, location=loc, - obstime=date, pressure=pressure, frame=AltAz) + obstime=date, pressure=pressure, frame=AltAz) self.a_radec = self.altaz.transform_to(ICRS) def writedb(self): @@ -280,8 +292,7 @@ def writedb(self): # The epoch field contains 3 dates, the actual epoch and the range # of valid dates which xepehm sets to +/- 100 days. - epoch0 = '{0}/{1:.9}/{2}'.format(mon, - day + ((h + (m + s/60.0) / 60.0) / 24.0), yr) + epoch0 = '{0}/{1:.9}/{2}'.format(mon, day + ((h + (m + s/60.0) / 60.0) / 24.0), yr) e = self._epoch + TimeDelta(-100, format='jd') dt = str(e) yr = int(dt[:4]) @@ -290,8 +301,7 @@ def writedb(self): h = int(dt[11:13]) m = int(dt[14:16]) s = float(dt[17:]) - epoch1 = '{0}/{1:.6}/{2}'.format(mon, - day + ((h + (m + s/60.0) / 60.0) / 24.0), yr) + epoch1 = '{0}/{1:.6}/{2}'.format(mon, day + ((h + (m + s/60.0) / 60.0) / 24.0), yr) e = e + TimeDelta(200, format='jd') dt = str(e) yr = int(dt[:4]) @@ -300,23 +310,22 @@ def writedb(self): h = int(dt[11:13]) m = int(dt[14:16]) s = float(dt[17:]) - epoch2 = '{0}/{1:.6}/{2}'.format(mon, - day + ((h + (m + s/60.0) / 60.0) / 24.0), yr) + epoch2 = '{0}/{1:.6}/{2}'.format(mon, day + ((h + (m + s/60.0) / 60.0) / 24.0), yr) epoch = '{0}| {1}| {2}'.format(epoch0, epoch1, epoch2) return '{0},{1},{2},{3},{4},{5:0.6f},{6:0.2f},{7},{8},{9},{10},{11}'.\ format(self.name, 'E', - epoch, - np.rad2deg(float(self._inc)), - np.rad2deg(float(self._raan)), - self._e, - np.rad2deg(float(self._ap)), - np.rad2deg(float(self._M)), - self._n, - self._decay, - self._orbit, - self._drag) + epoch, + np.rad2deg(float(self._inc)), + np.rad2deg(float(self._raan)), + self._e, + np.rad2deg(float(self._ap)), + np.rad2deg(float(self._M)), + self._n, + self._decay, + self._orbit, + self._drag) def _tle_to_float(tle_float): @@ -327,6 +336,7 @@ def _tle_to_float(tle_float): else: return float(tle_float[:dash] + "e-" + tle_float[dash+1:]) + def readtle(name, line1, line2): """Create an EarthSatellite object from a TLE description of an orbit. @@ -355,11 +365,10 @@ def readtle(name, line1, line2): d = int(ed) f = ed - d h = int(f * 24.0) - f = (f * 24.0 - h) + f = (f * 24.0 - h) m = int(f * 60.0) sec = (f * 60.0 - m) * 60.0 - date = '{0:04d}:{1:03d}:{2:02d}:{3:02d}:{4:02}'.format(epochyr, d, h, m , - sec) + date = '{0:04d}:{1:03d}:{2:02d}:{3:02d}:{4:02}'.format(epochyr, d, h, m, sec) s._epoch = Time('2000-01-01 00:00:00.000') @@ -379,6 +388,7 @@ def readtle(name, line1, line2): return s + def get_observer_look(sat_lon, sat_lat, sat_alt, utc_time, lon, lat, alt): """Calculate observers look angle to a satellite. @@ -437,6 +447,7 @@ def get_observer_look(sat_lon, sat_lat, sat_alt, utc_time, lon, lat, alt): return np.rad2deg(az_), np.rad2deg(el_) + class StationaryBody(Body): """Stationary body with fixed (az, el) coordinates. @@ -449,11 +460,10 @@ class StationaryBody(Body): Azimuth and elevation, either in 'D:M:S' string format, or float in rads name : string, optional Name of body - """ + def __init__(self, az, el, name=None): - self._azel = AltAz(az=angle_from_degrees(az), - alt=angle_from_degrees(el)) + self._azel = AltAz(az=angle_from_degrees(az), alt=angle_from_degrees(el)) if not name: name = "Az: {} El: {}".format(self._azel.az.to_string(sep=':', unit=u.deg), self._azel.alt.to_string(sep=':', unit=u.deg)) @@ -480,8 +490,8 @@ class NullBody(object): This body has the expected methods of :class:`Body`, but always returns NaNs for all coordinates. It is intended for use as a placeholder when no proper target object is available, i.e. as a dummy target. - """ + def __init__(self): self.name = 'Nothing' self.altaz = None diff --git a/katpoint/catalogue.py b/katpoint/catalogue.py index 973a6e5..69e4f25 100644 --- a/katpoint/catalogue.py +++ b/katpoint/catalogue.py @@ -15,6 +15,7 @@ ################################################################################ """Target catalogue.""" + from __future__ import print_function, division, absolute_import from builtins import object from past.builtins import basestring @@ -298,6 +299,7 @@ class Catalogue(object): the *same* catalogue. It also allows us to preserve the order in which the catalogue was assembled, which seems the most natural. """ + def __init__(self, targets=None, tags=None, add_specials=False, add_stars=False, antenna=None, flux_freq_MHz=None): self.lookup = defaultdict(list) @@ -369,7 +371,6 @@ def __getitem__(self, name): ------- target : :class:`Target` object, or None Associated target object, or None if no target was found - """ try: return self._targets_with_name(name)[-1] @@ -432,7 +433,6 @@ def add(self, targets, tags=None): >>> cat.add('Sun, special') >>> cat2 = Catalogue() >>> cat2.add(cat.targets) - """ if isinstance(targets, basestring) or isinstance(targets, Target): targets = [targets] @@ -490,7 +490,6 @@ def add_tle(self, lines, tags=None): '1 33442U 98067BL 09195.86837279 .00241454 37518-4 34022-3 0 3424\n', '2 33442 51.6315 144.2681 0003376 120.1747 240.0135 16.05240536 37575\n'] >>> cat2.add_tle(lines) - """ targets, tle = [], [] for line in lines: @@ -579,7 +578,6 @@ def remove(self, name): ---------- name : string Name of target to remove (may also be an alternate name of target) - """ target = self[name] if target is not None: @@ -597,7 +595,6 @@ def save(self, filename): ---------- filename : string Name of file to write catalogue to (overwriting existing contents) - """ open(filename, 'w').writelines([t.description + '\n' for t in self.targets]) @@ -624,7 +621,6 @@ def closest_to(self, target, timestamp=None, antenna=None): catalogue is empty min_dist : float Angular separation between *target* and *closest_target*, in degrees - """ if len(self.targets) == 0: return None, 180.0 @@ -699,7 +695,6 @@ def iterfilter(self, tags=None, flux_limit_Jy=None, flux_freq_MHz=None, az_limit >>> for t in cat.iterfilter(el_limit_deg=10): # Observe target t pass - """ tag_filter = tags is not None flux_filter = flux_limit_Jy is not None @@ -842,7 +837,6 @@ def filter(self, tags=None, flux_limit_Jy=None, flux_freq_MHz=None, az_limit_deg >>> cat3 = cat.filter(flux_limit_Jy=10) >>> cat4 = cat.filter(tags='special ~radec') >>> cat5 = cat.filter(dist_limit_deg=5, proximity_targets=cat['Sun']) - """ return Catalogue([target for target in self.iterfilter(tags, flux_limit_Jy, flux_freq_MHz, az_limit_deg, el_limit_deg, @@ -878,7 +872,6 @@ def sort(self, key='name', ascending=True, flux_freq_MHz=None, timestamp=None, a ------ ValueError If some required parameters are missing or key is unknown - """ # Set up index list that will be sorted if key == 'name': @@ -930,7 +923,6 @@ def visibility_list(self, timestamp=None, antenna=None, flux_freq_MHz=None, ante Second antenna of baseline pair (baseline vector points from *antenna* to *antenna2*), used to calculate delays and fringe rates per target - """ above_horizon = True timestamp = Timestamp(timestamp) diff --git a/katpoint/conversion.py b/katpoint/conversion.py index ce6640b..8364a73 100644 --- a/katpoint/conversion.py +++ b/katpoint/conversion.py @@ -52,7 +52,6 @@ def lla_to_ecef(lat_rad, lon_rad, alt_m): .. [NIMA2004] National Imagery and Mapping Agency, "Department of Defense World Geodetic System 1984," NIMA TR8350.2, Page 4-4, last updated June, 2004. - """ # WGS84 Defining Parameters a = 6378137.0 # semi-major axis of Earth in m @@ -104,7 +103,6 @@ def ecef_to_lla(x_m, y_m, z_m): .. [kaplan] Kaplan, "Understanding GPS: principles and applications," 1 ed., Norwood, MA 02062, USA: Artech House, Inc, 1996. .. [geo] Wikipedia entry, "Geodetic system", 2009. - """ # WGS84 Defining Parameters a = 6378137.0 # semi-major axis of Earth in m @@ -165,7 +163,6 @@ def ecef_to_lla2(x_m, y_m, z_m): This is a copy of the algorithm in the CONRAD codebase (from conradmisclib). It's nearly identical to :func:`ecef_to_lla`, but returns lon/lat in different ranges. - """ # WGS84 ellipsoid constants a = 6378137.0 # semi-major axis of Earth in m @@ -217,7 +214,6 @@ def enu_to_ecef(ref_lat_rad, ref_lon_rad, ref_alt_m, e_m, n_m, u_m): ------- x_m, y_m, z_m : float or array X, Y, Z coordinates, in metres - """ # ECEF coordinates of reference point ref_x_m, ref_y_m, ref_z_m = lla_to_ecef(ref_lat_rad, ref_lon_rad, ref_alt_m) @@ -252,7 +248,6 @@ def ecef_to_enu(ref_lat_rad, ref_lon_rad, ref_alt_m, x_m, y_m, z_m): ------- e_m, n_m, u_m : float or array East, North, Up coordinates, in metres - """ # ECEF coordinates of reference point ref_x_m, ref_y_m, ref_z_m = lla_to_ecef(ref_lat_rad, ref_lon_rad, ref_alt_m) @@ -287,7 +282,6 @@ def azel_to_enu(az_rad, el_rad): ------- e, n, u : float or array East, North, Up coordinates of unit vector - """ sin_az, cos_az = np.sin(az_rad), np.cos(az_rad) sin_el, cos_el = np.sin(el_rad), np.cos(el_rad) @@ -311,7 +305,6 @@ def enu_to_azel(e, n, u): ------- az_rad, el_rad : float or array Azimuth and elevation angle, in radians - """ return np.arctan2(e, n), np.arctan2(u, np.sqrt(e * e + n * n)) @@ -332,7 +325,6 @@ def hadec_to_enu(ha_rad, dec_rad, lat_rad): ------- e, n, u : float or array East, North, Up coordinates of unit vector - """ sin_ha, cos_ha = np.sin(ha_rad), np.cos(ha_rad) sin_dec, cos_dec = np.sin(dec_rad), np.cos(dec_rad) @@ -370,7 +362,6 @@ def enu_to_xyz(e, n, u, lat_rad): ---------- .. [TMS] Thompson, Moran, Swenson, "Interferometry and Synthesis in Radio Astronomy," 2nd ed., Wiley-VCH, 2004, pp. 86-89. - """ sin_lat, cos_lat = np.sin(lat_rad), np.cos(lat_rad) return -sin_lat * n + cos_lat * u, e, cos_lat * n + sin_lat * u diff --git a/katpoint/delay.py b/katpoint/delay.py index 1e99fba..82cc916 100644 --- a/katpoint/delay.py +++ b/katpoint/delay.py @@ -19,8 +19,8 @@ This implements the basic delay model used to calculate the delay contribution from each antenna, as well as a class that performs delay correction for a correlator. - """ + from __future__ import print_function, division, absolute_import from builtins import object, zip from past.builtins import basestring @@ -61,8 +61,8 @@ class DelayModel(Model): string, interpret it as a comma-separated (or whitespace-separated) sequence of parameters in their string form (i.e. a description string). The default is an empty model. - """ + def __init__(self, model=None): # Instantiate the relevant model parameters and register with base class params = [] @@ -89,7 +89,6 @@ def fromdelays(self, delays): ---------- delays : sequence of floats Model parameters in delay form (i.e. in seconds) - """ self.fromlist(delays * self._speeds) @@ -132,7 +131,6 @@ class DelayCorrection(object): ValueError If all antennas do not share the same reference position as `ref_ant` or `ref_ant` was not specified, or description string is invalid - """ # Maximum size for delay cache @@ -230,7 +228,6 @@ def _calculate_delays(self, target, timestamp, offset=None): ------- delays : sequence of *2M* floats Delays (one per correlator input) in seconds - """ if not offset: azel = target.azel(timestamp, self.ref_ant) @@ -264,7 +261,6 @@ def _cached_delays(self, target, timestamp, offset=None): See :meth:`_calculate_delays` for parameter and return lists, as these two methods can be used interchangeably. - """ delays = self._cache.pop(timestamp, None) if delays is None: @@ -319,7 +315,6 @@ def corrections(self, target, timestamp=None, next_timestamp=None, fringe rate value (in radians per second). If a sequence of *T* timestamps are provided, each input maps to an array of shape (*T*, 2). - """ if is_iterable(timestamp): # Append one more timestamp to get a slope for the last timestamp diff --git a/katpoint/ephem_extra.py b/katpoint/ephem_extra.py index 9f30131..cb180c0 100644 --- a/katpoint/ephem_extra.py +++ b/katpoint/ephem_extra.py @@ -16,7 +16,6 @@ """Enhancements to PyEphem.""" from __future__ import print_function, division, absolute_import -from builtins import object from past.builtins import basestring import numpy as np @@ -59,7 +58,6 @@ def _just_gimme_an_ascii_string(s): ------ UnicodeEncodeError, UnicodeDecodeError If the conversion fails due to the presence of non-ASCII characters - """ if isinstance(s, bytes) and not isinstance(s, str): # Only encoded bytes on Python 3 will end up here @@ -102,6 +100,5 @@ def wrap_angle(angle, period=2.0 * np.pi): """Wrap angle into interval centred on zero. This wraps the *angle* into the interval -*period* / 2 ... *period* / 2. - """ return (angle + 0.5 * period) % period - 0.5 * period diff --git a/katpoint/flux.py b/katpoint/flux.py index 5a36350..a5ed2ea 100644 --- a/katpoint/flux.py +++ b/katpoint/flux.py @@ -94,7 +94,6 @@ class FluxDensityModel(object): .. [KWP+1981] H. Kuehr, A. Witzel, I.I.K. Pauliny-Toth, U. Nauber, "A catalogue of extragalactic radio sources having flux densities greater than 1 Jy at 5 GHz," Astron. Astrophys. Suppl. Ser., 45, 367-430, 1981. - """ # Coefficients are zero by default, except for I _DEFAULT_COEFS = np.array([0.0, 0.0, 0.0, 0.0, 0.0, 0.0, # a, b, c, d, e, f @@ -175,7 +174,6 @@ def flux_density(self, freq_MHz): ------- flux_density : float, or array of floats of same shape as *freq_MHz* Flux density in Jy, or np.nan if the frequency is out of range - """ flux = self._flux_density_raw(freq_MHz) * self.iquv_scale[0] if is_iterable(freq_MHz): diff --git a/katpoint/model.py b/katpoint/model.py index ca422ca..b7592c1 100644 --- a/katpoint/model.py +++ b/katpoint/model.py @@ -18,8 +18,8 @@ This provides a base class for pointing and delay models, handling the loading, saving and display of parameters. - """ + from __future__ import print_function, division, absolute_import import future.utils from builtins import object, zip @@ -61,8 +61,8 @@ class Parameter(object): Attributes ---------- value_str - """ + def __init__(self, name, units, doc, from_str=float, to_str=str, value=None, default_value=0.0): self.name = name @@ -120,8 +120,8 @@ class Model(object): ---------- params : sequence of :class:`Parameter` objects Full set of model parameters in the expected order - """ + def __init__(self, params): self.header = {} self.params = OrderedDict((p.name, p) for p in params) @@ -229,7 +229,6 @@ def tofile(self, file_like): ---------- file-like : object File-like object with write() method representing config file - """ cfg = configparser.ConfigParser() cfg.add_section('header') @@ -247,7 +246,6 @@ def fromfile(self, file_like): ---------- file-like : object File-like object with readline() method representing config file - """ defaults = dict((p.name, p._to_str(p.default_value)) for p in self) if future.utils.PY2: @@ -283,7 +281,6 @@ def set(self, model=None): string, interpret it as a comma-separated (or whitespace- separated) sequence of parameters in their string form (i.e. a description string). The default is an empty model. - """ if isinstance(model, Model): if not isinstance(model, type(self)): diff --git a/katpoint/pointing.py b/katpoint/pointing.py index f76d436..d647999 100644 --- a/katpoint/pointing.py +++ b/katpoint/pointing.py @@ -17,8 +17,8 @@ """Pointing model. This implements a pointing model for a non-ideal antenna mount. - """ + from __future__ import print_function, division, absolute_import from builtins import range @@ -53,8 +53,8 @@ class PointingModel(Model): model parameters (defaults to sequence of zeroes). If it is a string, interpret it as a comma-separated (or whitespace-separated) sequence of parameters in their string form (i.e. a description string). - """ + def __init__(self, model=None): # There are two main types of parameter: angles and scale factors def angle_to_string(a): @@ -133,7 +133,6 @@ def offset(self, az, el): ---------- .. [Him1993] Himwich, "Pointing Model Derivation," Mark IV Field System Reference Manual, Version 8.2, 1 September 1993. - """ # Unpack parameters to make the code correspond to the maths P1, P2, P3, P4, P5, P6, P7, P8, \ @@ -171,7 +170,6 @@ def apply(self, az, el): Azimuth angle(s), corrected for pointing errors, in radians pointed_el : float or array Elevation angle(s), corrected for pointing errors, in radians - """ delta_az, delta_el = self.offset(az, el) return az + delta_az, el + delta_el @@ -193,7 +191,6 @@ def _jacobian(self, az, el): ------- d_corraz_d_az, d_corraz_d_el, d_correl_d_az, d_correl_d_el : float or array Elements of Jacobian matrix (or matrices) - """ # Unpack parameters to make the code correspond to the maths P1, P2, P3, P4, P5, P6, P7, P8, \ @@ -234,7 +231,6 @@ def reverse(self, pointed_az, pointed_el): Azimuth angle(s) before pointing correction, in radians el : float or array Elevation angle(s) before pointing correction, in radians - """ # Maximum difference between input az/el and pointing-corrected version of final output az/el tolerance = deg2rad(0.01 / 3600) @@ -308,7 +304,6 @@ def fit(self, az, el, delta_az, delta_el, sigma_daz=None, sigma_del=None, enable .. [PTV+1992] Press, Teukolsky, Vetterling, Flannery, "Numerical Recipes in C," 2nd Ed., pp. 671-681, 1992. Section 15.4: "General Linear Least Squares", available at ``_ - """ # Set default inputs if sigma_daz is None: diff --git a/katpoint/projection.py b/katpoint/projection.py index f58c865..9d188a6 100644 --- a/katpoint/projection.py +++ b/katpoint/projection.py @@ -123,8 +123,8 @@ 1993. .. [CB2002] Calabretta, Greisen, "Representations of celestial coordinates in FITS. II," Astronomy & Astrophysics, vol. 395, pp. 1077-1122, 2002. - """ + from __future__ import print_function, division, absolute_import import numpy as np @@ -199,7 +199,6 @@ def sphere_to_plane_sin(az0, el0, az, el): ----- This implements the original SIN projection as in AIPS, not the generalised 'slant orthographic' projection as in WCSLIB. - """ ortho_x, ortho_y, cos_theta = sphere_to_ortho(az0, el0, az, el) if np.any(cos_theta < 0.0): @@ -251,7 +250,6 @@ def plane_to_sphere_sin(az0, el0, x, y): ----- This implements the original SIN projection as in AIPS, not the generalised 'slant orthographic' projection as in WCSLIB. - """ if np.any(np.abs(el0) > np.pi / 2.0): raise ValueError('Elevation angle outside range of +- pi/2 radians') @@ -305,7 +303,6 @@ def sphere_to_plane_tan(az0, el0, az, el): ------ ValueError If input values are out of range, or target is too far from reference - """ ortho_x, ortho_y, cos_theta = sphere_to_ortho(az0, el0, az, el) if np.any(cos_theta <= 0.0): @@ -345,7 +342,6 @@ def plane_to_sphere_tan(az0, el0, x, y): ------ ValueError If input values are out of range - """ if np.any(np.abs(el0) > np.pi / 2.0): raise ValueError('Elevation angle outside range of +- pi/2 radians') @@ -393,7 +389,6 @@ def sphere_to_plane_arc(az0, el0, az, el): ------ ValueError If input values are out of range - """ ortho_x, ortho_y, cos_theta = sphere_to_ortho(az0, el0, az, el) # Safeguard the arccos, as over-ranging happens occasionally due to round-off error @@ -443,7 +438,6 @@ def plane_to_sphere_arc(az0, el0, x, y): ------ ValueError If input values are out of range, or the radius of (x, y) > pi - """ if np.any(np.abs(el0) > np.pi / 2.0): raise ValueError('Elevation angle outside range of +- pi/2 radians') @@ -508,7 +502,6 @@ def sphere_to_plane_stg(az0, el0, az, el): ------ ValueError If input values are out of range, or target point opposite to reference - """ ortho_x, ortho_y, cos_theta = sphere_to_ortho(az0, el0, az, el) den = 1.0 + cos_theta @@ -549,7 +542,6 @@ def plane_to_sphere_stg(az0, el0, x, y): ------ ValueError If input values are out of range - """ if np.any(np.abs(el0) > np.pi / 2.0): raise ValueError('Elevation angle outside range of +- pi/2 radians') @@ -602,7 +594,6 @@ def sphere_to_plane_car(az0, el0, az, el): Azimuth-like coordinate(s) on plane, in radians y : float or array Elevation-like coordinate(s) on plane, in radians - """ return az - az0, el - el0 @@ -633,7 +624,6 @@ def plane_to_sphere_car(az0, el0, x, y): Azimuth / right ascension / longitude of target point(s), in radians el : float or array Elevation / declination / latitude of target point(s), in radians - """ return az0 + x, el0 + y @@ -688,7 +678,6 @@ def sphere_to_plane_ssn(az0, el0, az, el): ----- This projection was originally introduced by Mattieu de Villiers for use in holography experiments. - """ return sphere_to_plane_sin(az, el, az0, el0) @@ -747,7 +736,6 @@ def plane_to_sphere_ssn(az0, el0, x, y): ----- This projection was originally introduced by Mattieu de Villiers for use in holography experiments. - """ if np.any(np.abs(el0) > np.pi / 2.0): raise ValueError('Elevation angle outside range of +- pi/2 radians') diff --git a/katpoint/refraction.py b/katpoint/refraction.py index 8544290..c3e1d88 100644 --- a/katpoint/refraction.py +++ b/katpoint/refraction.py @@ -17,8 +17,8 @@ """Refraction correction. This implements correction for refractive bending in the atmosphere. - """ + from __future__ import print_function, division, absolute_import from builtins import object, range @@ -125,8 +125,8 @@ class RefractionCorrection(object): ------ ValueError If the specified refraction model is unknown - """ + def __init__(self, model='VLBI Field System'): self.models = {'VLBI Field System': refraction_offset_vlbi} try: @@ -173,7 +173,6 @@ def apply(self, el, temperature_C, pressure_hPa, humidity_percent): ------- refracted_el : float or array Elevation angle(s), corrected for refraction, in radians - """ return el + self.offset(el, temperature_C, pressure_hPa, humidity_percent) @@ -198,7 +197,6 @@ def reverse(self, refracted_el, temperature_C, pressure_hPa, humidity_percent): ------- el : float or array Elevation angle(s) before refraction correction, in radians - """ # Maximum difference between input elevation and refraction-corrected version of final output elevation tolerance = deg2rad(0.01 / 3600) diff --git a/katpoint/stars.py b/katpoint/stars.py index f824822..03a35f5 100644 --- a/katpoint/stars.py +++ b/katpoint/stars.py @@ -27,7 +27,6 @@ registered at http://simbad.u-strasbg.fr/simbad/ were chosen. """ - import numpy as np import astropy.units as u from astropy.coordinates import SkyCoord, Longitude, Latitude, ICRS @@ -197,8 +196,7 @@ def readdb(line): s, m = np.modf(m * 60.0) m = int(np.floor(m)) s = s * 60.0 - e._epoch = Time('{0}-{1}-{2} {3:02d}:{4:02d}:{5}'.format(yr,mon,day, - h,m,s), scale='utc') + e._epoch = Time('{0}-{1}-{2} {3:02d}:{4:02d}:{5}'.format(yr, mon, day, h, m, s), scale='utc') e._inc = np.deg2rad(float(fields[3])) e._raan = np.deg2rad(float(fields[4])) e._e = float(fields[5]) @@ -214,6 +212,7 @@ def readdb(line): else: raise ValueError('Bogus: ' + line) + def _build_stars(): """ Builds the default catalogue. @@ -224,11 +223,13 @@ def _build_stars(): s = readdb(line) stars[s.name] = s + def star(name): """ Get a record from the catalogue """ return stars[name] + # Build catalogue _build_stars() diff --git a/katpoint/target.py b/katpoint/target.py index 753e4de..93b7b5a 100644 --- a/katpoint/target.py +++ b/katpoint/target.py @@ -15,6 +15,7 @@ ################################################################################ """Target object used for pointing and flux density calculation.""" + from __future__ import print_function, division, absolute_import from builtins import object, range from past.builtins import basestring @@ -118,8 +119,8 @@ class Target(object): ------ ValueError If description string has the wrong format - """ + def __init__(self, body, tags=None, aliases=None, flux_model=None, antenna=None, flux_freq_MHz=None): if isinstance(body, Target): body = body.description @@ -146,10 +147,10 @@ def __str__(self): descr += ', tags=%s' % (' '.join(self.tags),) if 'radec' in self.tags: descr += ', %s %s' % (self.body._radec.ra.to_string(unit=u.hour), - self.body._radec.dec.to_string(unit=u.deg)) + self.body._radec.dec.to_string(unit=u.deg)) if self.body_type == 'azel': descr += ', %s %s' % (self.body._azel.az.to_string(unit=u.deg), - self.body._azel.alt.to_string(unit=u.deg)) + self.body._azel.alt.to_string(unit=u.deg)) if self.body_type == 'gal': gal = self.body._radec.transform_to(Galactic) descr += ', %.4f %.4f' % (gal.l.deg, gal.b.deg) @@ -216,7 +217,6 @@ def _set_timestamp_antenna_defaults(self, timestamp, antenna): ------ ValueError If no antenna is specified, and no default antenna was set either - """ if timestamp is None: timestamp = Timestamp() @@ -243,7 +243,7 @@ def description(self): if names.startswith('Az:'): fields = [tags] fields += [self.body._azel.az.to_string(unit=u.deg), - self.body._azel.alt.to_string(unit=u.deg)] + self.body._azel.alt.to_string(unit=u.deg)] if fluxinfo: fields += [fluxinfo] @@ -252,7 +252,7 @@ def description(self): if names.startswith('Ra:'): fields = [tags] fields += [self.body._radec.dec.to_string(unit=u.hour), - self.body._radec.dec.to_string(unit=u.deg)] + self.body._radec.dec.to_string(unit=u.deg)] if fluxinfo: fields += [fluxinfo] @@ -306,7 +306,6 @@ def add_tags(self, tags): ------- target : :class:`Target` object Updated target object - """ if tags is None: tags = [] @@ -337,14 +336,13 @@ def azel(self, timestamp=None, antenna=None): ------ ValueError If no antenna is specified, and no default antenna was set either - """ timestamp, antenna = self._set_timestamp_antenna_defaults(timestamp, antenna) def _scalar_azel(t): """Calculate (az, el) coordinates for a single time instant.""" self.body.compute(antenna.earth_location, - Timestamp(t).to_ephem_date(), antenna.pressure) + Timestamp(t).to_ephem_date(), antenna.pressure) return self.body.altaz if is_iterable(timestamp): azel = np.array([_scalar_azel(t) for t in timestamp], dtype=object) @@ -380,7 +378,6 @@ def apparent_radec(self, timestamp=None, antenna=None): ------ ValueError If no antenna is specified, and no default antenna was set either - """ timestamp, antenna = self._set_timestamp_antenna_defaults(timestamp, antenna) @@ -420,7 +417,6 @@ def astrometric_radec(self, timestamp=None, antenna=None): ------ ValueError If no antenna is specified, and no default antenna was set either - """ if self.body_type == 'radec': radec = self.body._radec.transform_to(ICRS) @@ -470,7 +466,6 @@ def galactic(self, timestamp=None, antenna=None): ------ ValueError If no antenna is specified, and no default antenna was set either - """ if self.body_type == 'gal': gal = self.body._radec.transform_to(Galactic) @@ -522,13 +517,14 @@ def parallactic_angle(self, timestamp=None, antenna=None): .. _`AIPS++ Glossary`: http://www.astron.nl/aips++/docs/glossary/p.html .. _`Starlink Project`: http://www.starlink.rl.ac.uk - """ timestamp, antenna = self._set_timestamp_antenna_defaults(timestamp, antenna) # Get apparent hour angle and declination radec = self.apparent_radec(timestamp, antenna) ha = antenna.local_sidereal_time(timestamp) - radec.ra - return np.arctan2(np.sin(ha), np.tan(antenna.earth_location.lat.rad) * np.cos(radec.dec) - np.sin(radec.dec) * np.cos(ha)) + return np.arctan2(np.sin(ha), + np.tan(antenna.earth_location.lat.rad) * np.cos(radec.dec) + - np.sin(radec.dec) * np.cos(ha)) def geometric_delay(self, antenna2, timestamp=None, antenna=None): """Calculate geometric delay between two antennas pointing at target. @@ -571,7 +567,6 @@ def geometric_delay(self, antenna2, timestamp=None, antenna=None): from the reference antenna to the target, and the baseline vector pointing from the reference antenna to the second antenna, all in local ENU coordinates relative to the reference antenna. - """ timestamp, antenna = self._set_timestamp_antenna_defaults(timestamp, antenna) # Obtain baseline vector from reference antenna to second antenna @@ -722,7 +717,6 @@ def uvw(self, antenna2, timestamp=None, antenna=None): the first antenna, as opposed to the traditional XYZ coordinate system. This avoids having to convert (az, el) angles to (ha, dec) angles and uses linear algebra throughout instead. - """ timestamp, antenna = self._set_timestamp_antenna_defaults(timestamp, antenna) # Obtain basis vectors @@ -794,7 +788,6 @@ def flux_density(self, flux_freq_MHz=None): ------ ValueError If no frequency is specified, and no default frequency was set either - """ if flux_freq_MHz is None: flux_freq_MHz = self.flux_freq_MHz @@ -833,7 +826,6 @@ def flux_density_stokes(self, flux_freq_MHz=None): ------ ValueError If no frequency is specified, and no default frequency was set either - """ if flux_freq_MHz is None: flux_freq_MHz = self.flux_freq_MHz @@ -866,7 +858,6 @@ def separation(self, other_target, timestamp=None, antenna=None): ----- This calculates the azimuth and elevation of both targets at the given time and finds the angular distance between the two sets of coordinates. - """ # Get a common timestamp and antenna for both targets timestamp, antenna = self._set_timestamp_antenna_defaults(timestamp, antenna) @@ -911,7 +902,6 @@ def sphere_to_plane(self, az, el, timestamp=None, antenna=None, projection_type= Azimuth-like coordinate(s) on plane, in radians y : float or array Elevation-like coordinate(s) on plane, in radians - """ if coord_system == 'radec': # The target (ra, dec) coordinates will serve as reference point on the sphere @@ -952,7 +942,6 @@ def plane_to_sphere(self, x, y, timestamp=None, antenna=None, projection_type='A Azimuth or right ascension, in radians el : float or array Elevation or declination, in radians - """ if coord_system == 'radec': # The target (ra, dec) coordinates will serve as reference point on the sphere @@ -994,7 +983,6 @@ def construct_target_params(description): ------ ValueError If *description* has the wrong format - """ try: description.encode('ascii') @@ -1162,7 +1150,6 @@ def construct_azel_target(az, el): ------- target : :class:`Target` object Constructed target object - """ return Target(StationaryBody(az, el), 'azel') @@ -1190,7 +1177,6 @@ def construct_radec_target(ra, dec): ------- target : :class:`Target` object Constructed target object - """ body = FixedBody() # First try to interpret the string as decimal degrees diff --git a/katpoint/test/__init__.py b/katpoint/test/__init__.py index 2148ac2..86ce79a 100644 --- a/katpoint/test/__init__.py +++ b/katpoint/test/__init__.py @@ -55,6 +55,8 @@ def suite(): testsuite.addTests(loader.loadTestsFromModule(test_pointing)) testsuite.addTests(loader.loadTestsFromModule(test_refraction)) testsuite.addTests(loader.loadTestsFromModule(test_delay)) + testsuite.addTests(loader.loadTestsFromModule(test_body)) + testsuite.addTests(loader.loadTestsFromModule(test_stars)) return testsuite diff --git a/katpoint/test/test_antenna.py b/katpoint/test/test_antenna.py index bbf71a7..9cc8835 100644 --- a/katpoint/test/test_antenna.py +++ b/katpoint/test/test_antenna.py @@ -34,17 +34,18 @@ def primary_angle(x): class TestAntenna(unittest.TestCase): """Test :class:`katpoint.antenna.Antenna`.""" + def setUp(self): self.valid_antennas = [ 'XDM, -25:53:23.0, 27:41:03.0, 1406.1086, 15.0', 'FF1, -30:43:17.3, 21:24:38.5, 1038.0, 12.0, 18.4 -8.7 0.0', ('FF2, -30:43:17.3, 21:24:38.5, 1038.0, 12.0, 86.2 25.5 0.0, ' '-0:06:39.6 0 0 0 0 0 0:09:48.9, 1.16') - ] + ] self.invalid_antennas = [ 'XDM, -25:53:23.05075, 27:41:03.0', '', - ] + ] self.timestamp = '2009/07/07 08:36:20' def test_construct_antenna(self): diff --git a/katpoint/test/test_body.py b/katpoint/test/test_body.py index a1cfe2e..55864c3 100644 --- a/katpoint/test/test_body.py +++ b/katpoint/test/test_body.py @@ -17,6 +17,7 @@ class TestFixedBody(unittest.TestCase): """Test for the FixedBody class.""" + def test_compute(self): """Test compute method""" lat = Latitude('10:00:00.000', unit=u.deg) @@ -29,8 +30,7 @@ def test_compute(self): body._radec = SkyCoord(ra=ra, dec=dec, frame=ICRS) body.compute(EarthLocation(lat=lat, lon=lon, height=0.0), date, 0.0) - self.assertEqual(body.a_radec.ra.to_string(sep=':', unit=u.hour), - '10:10:40.123') + self.assertEqual(body.a_radec.ra.to_string(sep=':', unit=u.hour), '10:10:40.123') self.assertEqual(body.a_radec.dec.to_string(sep=':'), '40:20:50.567') # 326:05:54.8 51:21:18.5 @@ -93,32 +93,33 @@ def test_earth_satellite(self): self.assertEqual(sat._drag, 1.e-04) # This is xephem database record that pyephem generates - xephem = ' GPS BIIA-21 (PRN 09) ,E,9/23.32333151/2019| 6/15.3242/2019| 1/1.32422/2020,55.4408,61.379002,0.0191986,78.180199,283.9935,2.0056172,1.2e-07,10428,9.9999997e-05' + xephem = ' GPS BIIA-21 (PRN 09) ,E,9/23.32333151/2019| 6/15.3242/2019| 1/1.32422/2020,' \ + '55.4408,61.379002,0.0191986,78.180199,283.9935,2.0056172,1.2e-07,10428,9.9999997e-05' rec = sat.writedb() self.assertEqual(rec.split(',')[0], xephem.split(',')[0]) self.assertEqual(rec.split(',')[1], xephem.split(',')[1]) self.assertEqual(rec.split(',')[2].split('|')[0].split('/')[0], - xephem.split(',')[2].split('|')[0].split('/')[0]) + xephem.split(',')[2].split('|')[0].split('/')[0]) self.assertAlmostEqual(float(rec.split(',')[2].split('|')[0].split('/')[1]), - float(xephem.split(',')[2].split('|')[0].split('/')[1])) + float(xephem.split(',')[2].split('|')[0].split('/')[1])) self.assertEqual(rec.split(',')[2].split('|')[0].split('/')[2], - xephem.split(',')[2].split('|')[0].split('/')[2]) + xephem.split(',')[2].split('|')[0].split('/')[2]) self.assertEqual(rec.split(',')[2].split('|')[1].split('/')[0], - xephem.split(',')[2].split('|')[1].split('/')[0]) + xephem.split(',')[2].split('|')[1].split('/')[0]) self.assertAlmostEqual(float(rec.split(',')[2].split('|')[1].split('/')[1]), - float(xephem.split(',')[2].split('|')[1].split('/')[1]), places=2) + float(xephem.split(',')[2].split('|')[1].split('/')[1]), places=2) self.assertEqual(rec.split(',')[2].split('|')[1].split('/')[2], - xephem.split(',')[2].split('|')[1].split('/')[2]) + xephem.split(',')[2].split('|')[1].split('/')[2]) self.assertEqual(rec.split(',')[2].split('|')[2].split('/')[0], - xephem.split(',')[2].split('|')[2].split('/')[0]) + xephem.split(',')[2].split('|')[2].split('/')[0]) self.assertAlmostEqual(float(rec.split(',')[2].split('|')[2].split('/')[1]), - float(xephem.split(',')[2].split('|')[2].split('/')[1]), places=2) + float(xephem.split(',')[2].split('|')[2].split('/')[1]), places=2) self.assertEqual(rec.split(',')[2].split('|')[2].split('/')[2], - xephem.split(',')[2].split('|')[2].split('/')[2]) + xephem.split(',')[2].split('|')[2].split('/')[2]) self.assertEqual(rec.split(',')[3], xephem.split(',')[3]) @@ -140,8 +141,7 @@ def test_earth_satellite(self): sat.compute(EarthLocation(lat=lat, lon=lon, height=elevation), date, 0.0) # 3:32:59.21' '-2:04:36.3' - self.assertEqual(sat.a_radec.ra.to_string(sep=':', unit=u.hour), - '3:32:56.7813') + self.assertEqual(sat.a_radec.ra.to_string(sep=':', unit=u.hour), '3:32:56.7813') self.assertEqual(sat.a_radec.dec.to_string(sep=':'), '-2:04:35.4329') # 280:32:07.2 -54:06:14.4 diff --git a/katpoint/test/test_catalogue.py b/katpoint/test/test_catalogue.py index 91056e2..47b85f5 100644 --- a/katpoint/test/test_catalogue.py +++ b/katpoint/test/test_catalogue.py @@ -29,6 +29,7 @@ class TestCatalogueConstruction(unittest.TestCase): """Test construction of catalogues.""" + def setUp(self): self.tle_lines = ['# Comment ignored\n', 'GPS BIIA-21 (PRN 09) \n', @@ -142,6 +143,7 @@ def test_skip_empty(self): class TestCatalogueFilterSort(unittest.TestCase): """Test filtering and sorting of catalogues.""" + def setUp(self): self.flux_target = katpoint.Target('flux, radec, 0.0, 0.0, (1.0 2.0 2.0 0.0 0.0)') self.antenna = katpoint.Antenna('XDM, -25:53:23.05075, 27:41:03.36453, 1406.1086, 15.0') diff --git a/katpoint/test/test_conversion.py b/katpoint/test/test_conversion.py index 6128301..0620f5b 100644 --- a/katpoint/test/test_conversion.py +++ b/katpoint/test/test_conversion.py @@ -34,6 +34,7 @@ def primary_angle(x): class TestGeodetic(unittest.TestCase): """Closure tests for geodetic coordinate transformations.""" + def setUp(self): N = 1000 self.lat = 0.999 * np.pi * (np.random.rand(N) - 0.5) @@ -69,6 +70,7 @@ def test_ecef_to_enu(self): class TestSpherical(unittest.TestCase): """Closure tests for spherical coordinate transformations.""" + def setUp(self): N = 1000 self.az = Angle(2.0 * np.pi * np.random.rand(N), unit=u.rad) diff --git a/katpoint/test/test_delay.py b/katpoint/test/test_delay.py index 91a3410..7d3cff0 100644 --- a/katpoint/test/test_delay.py +++ b/katpoint/test/test_delay.py @@ -33,6 +33,7 @@ class TestDelayModel(unittest.TestCase): """Test antenna delay model.""" + def test_construct_save_load(self): """Test construction / save / load of delay model.""" m = katpoint.DelayModel('1.0, -2.0, -3.0, 4.123, 5.0, 6.0') @@ -60,6 +61,7 @@ def test_construct_save_load(self): class TestDelayCorrection(unittest.TestCase): """Test correlator delay corrections.""" + def setUp(self): self.target1 = katpoint.construct_azel_target('45:00:00.0', '75:00:00.0') self.target2 = katpoint.Target('Sun, special') @@ -118,16 +120,14 @@ def test_offset(self): """Test target offset.""" azel = self.target1.azel(self.ts, self.ant1) offset = dict(projection_type='SIN') - target3 = katpoint.construct_azel_target( - azel.az - Angle(1.0, unit=u.deg), - azel.alt - Angle(1.0, unit=u.deg)) + target3 = katpoint.construct_azel_target(azel.az - Angle(1.0, unit=u.deg), + azel.alt - Angle(1.0, unit=u.deg)) x, y = target3.sphere_to_plane(azel.az.rad, azel.alt.rad, self.ts, self.ant1, **offset) offset['x'] = x offset['y'] = y extra_delay = self.delays.extra_delay delay0, phase0 = self.delays.corrections(target3, self.ts, offset=offset) - delay1, phase1 = self.delays.corrections(target3, self.ts, - self.ts + 1.0, offset) + delay1, phase1 = self.delays.corrections(target3, self.ts, self.ts + 1.0, offset) # Conspire to return to special target1 self.assertEqual(delay0['A2h'], extra_delay, 'Delay for ant2h should be zero') self.assertEqual(delay0['A2v'], extra_delay, 'Delay for ant2v should be zero') @@ -145,12 +145,11 @@ def test_offset(self): offset['y'] = y extra_delay = self.delays.extra_delay delay0, phase0 = self.delays.corrections(target4, self.ts, offset=offset) - delay1, phase1 = self.delays.corrections(target4, self.ts, - self.ts + 1.0, offset) + delay1, phase1 = self.delays.corrections(target4, self.ts, self.ts + 1.0, offset) # Conspire to return to special target1 - #np.testing.assert_almost_equal(delay0['A2h'], extra_delay, decimal=15) - #np.testing.assert_almost_equal(delay0['A2v'], extra_delay, decimal=15) - #np.testing.assert_almost_equal(delay1['A2h'][0], extra_delay, decimal=15) - #np.testing.assert_almost_equal(delay1['A2v'][0], extra_delay, decimal=15) - #np.testing.assert_almost_equal(delay1['A2h'][1], 0.0, decimal=15) - #np.testing.assert_almost_equal(delay1['A2v'][1], 0.0, decimal=15) + # np.testing.assert_almost_equal(delay0['A2h'], extra_delay, decimal=15) + # np.testing.assert_almost_equal(delay0['A2v'], extra_delay, decimal=15) + # np.testing.assert_almost_equal(delay1['A2h'][0], extra_delay, decimal=15) + # np.testing.assert_almost_equal(delay1['A2v'][0], extra_delay, decimal=15) + # np.testing.assert_almost_equal(delay1['A2h'][1], 0.0, decimal=15) + # np.testing.assert_almost_equal(delay1['A2v'][1], 0.0, decimal=15) diff --git a/katpoint/test/test_flux.py b/katpoint/test/test_flux.py index cc732d9..a6fb456 100644 --- a/katpoint/test/test_flux.py +++ b/katpoint/test/test_flux.py @@ -26,6 +26,7 @@ class TestFluxDensityModel(unittest.TestCase): """Test flux density model calculation.""" + def setUp(self): self.unit_model = katpoint.FluxDensityModel(100., 200., [0.]) self.unit_model2 = katpoint.FluxDensityModel(100., 200., [0.]) diff --git a/katpoint/test/test_model.py b/katpoint/test/test_model.py index 14de1d3..17c2a24 100644 --- a/katpoint/test/test_model.py +++ b/katpoint/test/test_model.py @@ -28,6 +28,7 @@ class TestModel(unittest.TestCase): """Test generic model.""" + def new_params(self): """Generate fresh set of parameters (otherwise models share the same ones).""" params = [] diff --git a/katpoint/test/test_pointing.py b/katpoint/test/test_pointing.py index a7207d1..f802544 100644 --- a/katpoint/test/test_pointing.py +++ b/katpoint/test/test_pointing.py @@ -32,6 +32,7 @@ def primary_angle(x): class TestPointingModel(unittest.TestCase): """Test pointing model.""" + def setUp(self): az_range = katpoint.deg2rad(np.arange(-185.0, 275.0, 5.0)) el_range = katpoint.deg2rad(np.arange(0.0, 86.0, 1.0)) @@ -55,7 +56,7 @@ def test_pointing_model_load_save(self): self.assertEqual(pm4.description, pm.description, 'Saving pointing model to string and loading it again failed') self.assertEqual(pm4, pm, 'Pointing models should be equal') self.assertNotEqual(pm2, pm, 'Pointing models should be inequal') - #np.testing.assert_almost_equal(pm4.values(), pm.values(), decimal=6) + # np.testing.assert_almost_equal(pm4.values(), pm.values(), decimal=6) for (v4, v) in zip(pm4.values(), pm.values()): if type(v4) == float: np.testing.assert_almost_equal(v4, v, decimal=6) diff --git a/katpoint/test/test_projection.py b/katpoint/test/test_projection.py index 4f0d1e9..e3a9e52 100644 --- a/katpoint/test/test_projection.py +++ b/katpoint/test/test_projection.py @@ -47,6 +47,7 @@ def primary_angle(x): class TestProjectionSIN(unittest.TestCase): """Test orthographic projection.""" + def setUp(self): self.plane_to_sphere = katpoint.plane_to_sphere['SIN'] self.sphere_to_plane = katpoint.sphere_to_plane['SIN'] @@ -148,6 +149,7 @@ def test_corner_cases(self): class TestProjectionTAN(unittest.TestCase): """Test gnomonic projection.""" + def setUp(self): self.plane_to_sphere = katpoint.plane_to_sphere['TAN'] self.sphere_to_plane = katpoint.sphere_to_plane['TAN'] @@ -251,6 +253,7 @@ def test_corner_cases(self): class TestProjectionARC(unittest.TestCase): """Test zenithal equidistant projection.""" + def setUp(self): self.plane_to_sphere = katpoint.plane_to_sphere['ARC'] self.sphere_to_plane = katpoint.sphere_to_plane['ARC'] @@ -364,6 +367,7 @@ def test_corner_cases(self): class TestProjectionSTG(unittest.TestCase): """Test stereographic projection.""" + def setUp(self): self.plane_to_sphere = katpoint.plane_to_sphere['STG'] self.sphere_to_plane = katpoint.sphere_to_plane['STG'] @@ -465,6 +469,7 @@ def test_corner_cases(self): class TestProjectionCAR(unittest.TestCase): """Test plate carree projection.""" + def setUp(self): self.plane_to_sphere = katpoint.plane_to_sphere['CAR'] self.sphere_to_plane = katpoint.sphere_to_plane['CAR'] @@ -506,6 +511,7 @@ def plane_to_sphere_original_ssn(target_az, target_el, ll, mm): class TestProjectionSSN(unittest.TestCase): """Test swapped orthographic projection.""" + def setUp(self): self.plane_to_sphere = katpoint.plane_to_sphere['SSN'] self.sphere_to_plane = katpoint.sphere_to_plane['SSN'] diff --git a/katpoint/test/test_refraction.py b/katpoint/test/test_refraction.py index aca945e..5d4273c 100644 --- a/katpoint/test/test_refraction.py +++ b/katpoint/test/test_refraction.py @@ -32,6 +32,7 @@ def primary_angle(x): class TestRefractionCorrection(unittest.TestCase): """Test refraction correction.""" + def setUp(self): self.rc = katpoint.RefractionCorrection() self.el = katpoint.deg2rad(np.arange(0.0, 90.1, 0.1)) diff --git a/katpoint/test/test_stars.py b/katpoint/test/test_stars.py index c1bed14..4963f31 100644 --- a/katpoint/test/test_stars.py +++ b/katpoint/test/test_stars.py @@ -5,10 +5,12 @@ from katpoint.stars import readdb + class test_stars(unittest.TestCase): def test_earth_satellite(self): - record = 'GPS BIIA-21 (PR,E,9/23.32333151/2019| 6/15.3242/2019| 1/1.32422/2020,55.4408,61.379002,0.0191986,78.180199,283.9935,2.0056172,1.2e-07,10428,9.9999997e-05' + record = 'GPS BIIA-21 (PR,E,9/23.32333151/2019| 6/15.3242/2019| 1/1.32422/2020,' \ + '55.4408,61.379002,0.0191986,78.180199,283.9935,2.0056172,1.2e-07,10428,9.9999997e-05' e = readdb(record) self.assertEqual(e.name, 'GPS BIIA-21 (PR') @@ -19,10 +21,10 @@ def test_earth_satellite(self): self.assertEqual(e._ap, np.deg2rad(78.180199)) self.assertEqual(e._M, np.deg2rad(283.9935)) self.assertEqual(e._n, 2.0056172) - self.assertEqual(e._decay,1.2e-07) + self.assertEqual(e._decay, 1.2e-07) self.assertEqual(e._orbit, 10428) - self.assertEqual(e._drag,9.9999997e-05) + self.assertEqual(e._drag, 9.9999997e-05) def test_star(self): record = 'Sadr,f|S|F8,20:22:13.7|2.43,40:15:24|-0.93,2.23,2000,0' - e = readdb(record) + readdb(record) diff --git a/katpoint/test/test_target.py b/katpoint/test/test_target.py index a798488..70c081f 100644 --- a/katpoint/test/test_target.py +++ b/katpoint/test/test_target.py @@ -33,6 +33,7 @@ class TestTargetConstruction(unittest.TestCase): """Test construction of targets from strings and vice versa.""" + def setUp(self): self.valid_targets = ['azel, -30.0, 90.0', ', azel, 180, -45:00:00.0', @@ -126,12 +127,12 @@ def test_construct_target(self): def test_constructed_coords(self): """Test whether calculated coordinates match those with which it is constructed.""" - #azel = katpoint.Target(self.azel_target) - #calc_azel = azel.azel() - #calc_az = calc_azel.az; - #calc_el = calc_azel.alt; - #self.assertEqual(calc_az.deg, 10.0, 'Calculated az does not match specified value in azel target') - #self.assertEqual(calc_el.deg, -10.0, 'Calculated el does not match specified value in azel target') + # azel = katpoint.Target(self.azel_target) + # calc_azel = azel.azel() + # calc_az = calc_azel.az; + # calc_el = calc_azel.alt; + # self.assertEqual(calc_az.deg, 10.0, 'Calculated az does not match specified value in azel target') + # self.assertEqual(calc_el.deg, -10.0, 'Calculated el does not match specified value in azel target') radec = katpoint.Target(self.radec_target) calc_radec = radec.radec() calc_ra = calc_radec.ra @@ -164,12 +165,13 @@ def test_add_tags(self): class TestTargetCalculations(unittest.TestCase): """Test various calculations involving antennas and timestamps.""" + def setUp(self): self.target = katpoint.construct_azel_target('45:00:00.0', '75:00:00.0') self.ant1 = katpoint.Antenna('A1, -31.0, 18.0, 0.0, 12.0, 0.0 0.0 0.0') self.ant2 = katpoint.Antenna('A2, -31.0, 18.0, 0.0, 12.0, 10.0 -10.0 0.0') self.ts = katpoint.Timestamp('2013-08-14 09:25') - #self.uvw = [10.822861713680807, -9.103057965680664, -2.220446049250313e-16] + # self.uvw = [10.822861713680807, -9.103057965680664, -2.220446049250313e-16] self.uvw = [10.820796672358002, -9.1055125816993954, -2.22044604925e-16] def test_coords(self): @@ -246,7 +248,7 @@ def test_lmn(self): radec = target.radec(timestamp=self.ts, antenna=self.ant1) l, m, n = pointing.lmn(radec.ra.rad, radec.dec.rad) expected_l, expected_m = pointing.sphere_to_plane( - radec.ra.rad, radec.dec.rad, projection_type='SIN', coord_system='radec') + radec.ra.rad, radec.dec.rad, projection_type='SIN', coord_system='radec') expected_n = np.sqrt(1.0 - expected_l**2 - expected_m**2) np.testing.assert_almost_equal(l, expected_l, decimal=12) np.testing.assert_almost_equal(m, expected_m, decimal=12) diff --git a/katpoint/test/test_timestamp.py b/katpoint/test/test_timestamp.py index 725c3aa..bbc77a9 100644 --- a/katpoint/test/test_timestamp.py +++ b/katpoint/test/test_timestamp.py @@ -26,6 +26,7 @@ class TestTimestamp(unittest.TestCase): """Test timestamp creation and conversion.""" + def setUp(self): self.valid_timestamps = [(1248186982.3980861, '2009-07-21 14:36:22.398'), (Time('2009-07-21 02:52:12.34'), '2009-07-21 02:52:12.340'), @@ -71,7 +72,7 @@ def test_numerical_timestamp(self): self.assertEqual(t, eval('katpoint.' + repr(t))) self.assertEqual(float(t), self.valid_timestamps[0][0]) t = katpoint.Timestamp(self.valid_timestamps[1][0]) - #self.assertAlmostEqual(t.to_ephem_date(), self.valid_timestamps[1][0], places=9) + # self.assertAlmostEqual(t.to_ephem_date(), self.valid_timestamps[1][0], places=9) self.assertEqual(t.to_ephem_date().value, self.valid_timestamps[1][0]) try: self.assertEqual(hash(t), hash(t + 0.0), 'Timestamp hashes not equal') diff --git a/katpoint/timestamp.py b/katpoint/timestamp.py index 28a7f2c..46cc354 100644 --- a/katpoint/timestamp.py +++ b/katpoint/timestamp.py @@ -59,8 +59,8 @@ class Timestamp(object): --------- secs : float Timestamp as UTC seconds since Unix epoch - """ + def __init__(self, timestamp=None): if isinstance(timestamp, basestring): try: @@ -191,15 +191,14 @@ def to_ephem_date(self): int_secs = math.floor(self.secs) timetuple = list(time.gmtime(int_secs)[:6]) timetuple[5] += self.secs - int_secs - return Time('{0}-{1:02}-{2:02} {3:02}:{4:02}:{5:02}'.format(timetuple[0], - timetuple[1], timetuple[2], timetuple[3], - timetuple[4], timetuple[5])) + return Time('{0}-{1:02}-{2:02} {3:02}:{4:02}:{5:02}'.format(*timetuple)) def to_mjd(self): """Convert timestamp to Modified Julian Day (MJD).""" djd = self.to_ephem_date() return djd.mjd + def decode(s): """Decodes a date string """ @@ -216,22 +215,22 @@ def decode(s): # time without fractional seconds try: d = time.strptime(s, '%Y-%m-%d %H:%M:%S') - except: + except ValueError: try: d = time.strptime(s, '%Y-%m-%d %H:%M') - except: + except ValueError: try: d = time.strptime(s, '%Y-%m-%d %H') - except: + except ValueError: try: d = time.strptime(s, '%Y-%m-%d') - except: + except ValueError: try: d = time.strptime(s, '%Y-%m') - except: + except ValueError: try: d = time.strptime(s, '%Y') - except: + except ValueError: raise ValueError('unable to decode date string') # Convert to a unix time and add the fractional seconds @@ -240,7 +239,7 @@ def decode(s): # Back to a tuple d = time.localtime(u) - return time.strftime('%Y-%m-%d %H:%M:%S',d) + f + return time.strftime('%Y-%m-%d %H:%M:%S', d) + f def now(): diff --git a/scripts/psrcat_sources.py b/scripts/psrcat_sources.py index ffd3150..16f75bd 100644 --- a/scripts/psrcat_sources.py +++ b/scripts/psrcat_sources.py @@ -1,4 +1,4 @@ -#/usr/bin/env python +# /usr/bin/env python # # Tool that converts the ATNF PSRCAT database into a katpoint Catalogue. # @@ -17,6 +17,8 @@ # 31 October 2014 # +from __future__ import print_function + import argparse import re @@ -34,16 +36,16 @@ raise RuntimeError("Please obtain a long ephemeris file from the PSRCAT package") # Regexp that finds key-value pair associated with pulsar -key_val = re.compile('^([A-Z0-9_]+)\s+(\S+)') +key_val = re.compile(r'^([A-Z0-9_]+)\s+(\S+)') # Regexp that finds SNR name associated with pulsar -snr = re.compile('SNR:(PWN:)*(.+?)[[(,;$]') +snr = re.compile(r'SNR:(PWN:)*(.+?)[[(,;$]') # Regexp that finds single flux measurement associated with pulsar -flux_bin = re.compile('^S(\d{3,4})$') +flux_bin = re.compile(r'^S(\d{3,4})$') # Turn database file into list of dicts, one per pulsar pulsars = [] psr = {} -for line in file(args.db_file): +for line in open(args.db_file): if line.startswith('#'): continue if line.startswith('@'): @@ -89,4 +91,4 @@ description = '%s, radec psr, %s, %s' % (names, ra, dec) if flux_model: description += ', ' + flux_model.description - print description + print(description) From 8b4aae334d132adb28b7181b8e8201e93c5cbc24 Mon Sep 17 00:00:00 2001 From: Ludwig Schwardt Date: Thu, 2 Jul 2020 22:17:43 +0200 Subject: [PATCH 003/122] Update copyright notice to 2020 --- LICENSE.txt | 2 +- katpoint/antenna.py | 2 +- katpoint/bodies.py | 2 +- katpoint/catalogue.py | 2 +- katpoint/conversion.py | 2 +- katpoint/delay.py | 2 +- katpoint/ephem_extra.py | 2 +- katpoint/flux.py | 2 +- katpoint/model.py | 2 +- katpoint/pointing.py | 2 +- katpoint/projection.py | 2 +- katpoint/refraction.py | 2 +- katpoint/stars.py | 2 +- katpoint/target.py | 2 +- katpoint/test/__init__.py | 2 +- katpoint/test/aips_projection/build_module.sh | 2 +- katpoint/test/test_antenna.py | 2 +- katpoint/test/test_body.py | 16 ++++++++++++++++ katpoint/test/test_catalogue.py | 2 +- katpoint/test/test_conversion.py | 2 +- katpoint/test/test_delay.py | 2 +- katpoint/test/test_flux.py | 2 +- katpoint/test/test_model.py | 2 +- katpoint/test/test_pointing.py | 2 +- katpoint/test/test_projection.py | 2 +- katpoint/test/test_refraction.py | 2 +- katpoint/test/test_stars.py | 16 ++++++++++++++++ katpoint/test/test_target.py | 2 +- katpoint/test/test_timestamp.py | 2 +- katpoint/timestamp.py | 2 +- scripts/atca_calibrators.py | 2 +- scripts/bae_optical_pointing_sources.py | 2 +- scripts/kuehr1Jy_sources.py | 2 +- scripts/merge_catalogues.py | 2 +- scripts/pkscat90_sources.py | 2 +- scripts/tabara_sources.py | 2 +- scripts/validate_targets.py | 2 +- setup.py | 2 +- 38 files changed, 68 insertions(+), 36 deletions(-) diff --git a/LICENSE.txt b/LICENSE.txt index 032fa9f..c90b618 100644 --- a/LICENSE.txt +++ b/LICENSE.txt @@ -1,4 +1,4 @@ -Copyright (c) 2009-2019, National Research Foundation (Square Kilometre Array) +Copyright (c) 2009-2020, National Research Foundation (SARAO) All rights reserved. Redistribution and use in source and binary forms, with or without modification, diff --git a/katpoint/antenna.py b/katpoint/antenna.py index 2aae5a8..10a1c44 100644 --- a/katpoint/antenna.py +++ b/katpoint/antenna.py @@ -1,5 +1,5 @@ ################################################################################ -# Copyright (c) 2009-2019, National Research Foundation (Square Kilometre Array) +# Copyright (c) 2009-2020, National Research Foundation (SARAO) # # Licensed under the BSD 3-Clause License (the "License"); you may not use # this file except in compliance with the License. You may obtain a copy diff --git a/katpoint/bodies.py b/katpoint/bodies.py index 77867c4..5650e72 100644 --- a/katpoint/bodies.py +++ b/katpoint/bodies.py @@ -1,5 +1,5 @@ ################################################################################ -# Copyright (c) 2009-2019, National Research Foundation (Square Kilometre Array) +# Copyright (c) 2009-2020, National Research Foundation (SARAO) # # Licensed under the BSD 3-Clause License (the "License"); you may not use # this file except in compliance with the License. You may obtain a copy diff --git a/katpoint/catalogue.py b/katpoint/catalogue.py index 69e4f25..352b41f 100644 --- a/katpoint/catalogue.py +++ b/katpoint/catalogue.py @@ -1,5 +1,5 @@ ################################################################################ -# Copyright (c) 2009-2019, National Research Foundation (Square Kilometre Array) +# Copyright (c) 2009-2020, National Research Foundation (SARAO) # # Licensed under the BSD 3-Clause License (the "License"); you may not use # this file except in compliance with the License. You may obtain a copy diff --git a/katpoint/conversion.py b/katpoint/conversion.py index 8364a73..a6ab046 100644 --- a/katpoint/conversion.py +++ b/katpoint/conversion.py @@ -1,5 +1,5 @@ ################################################################################ -# Copyright (c) 2009-2019, National Research Foundation (Square Kilometre Array) +# Copyright (c) 2009-2020, National Research Foundation (SARAO) # # Licensed under the BSD 3-Clause License (the "License"); you may not use # this file except in compliance with the License. You may obtain a copy diff --git a/katpoint/delay.py b/katpoint/delay.py index 82cc916..a6b9476 100644 --- a/katpoint/delay.py +++ b/katpoint/delay.py @@ -1,5 +1,5 @@ ################################################################################ -# Copyright (c) 2009-2019, National Research Foundation (Square Kilometre Array) +# Copyright (c) 2009-2020, National Research Foundation (SARAO) # # Licensed under the BSD 3-Clause License (the "License"); you may not use # this file except in compliance with the License. You may obtain a copy diff --git a/katpoint/ephem_extra.py b/katpoint/ephem_extra.py index cb180c0..8926915 100644 --- a/katpoint/ephem_extra.py +++ b/katpoint/ephem_extra.py @@ -1,5 +1,5 @@ ################################################################################ -# Copyright (c) 2009-2019, National Research Foundation (Square Kilometre Array) +# Copyright (c) 2009-2020, National Research Foundation (SARAO) # # Licensed under the BSD 3-Clause License (the "License"); you may not use # this file except in compliance with the License. You may obtain a copy diff --git a/katpoint/flux.py b/katpoint/flux.py index a5ed2ea..95b73f6 100644 --- a/katpoint/flux.py +++ b/katpoint/flux.py @@ -1,5 +1,5 @@ ################################################################################ -# Copyright (c) 2009-2019, National Research Foundation (Square Kilometre Array) +# Copyright (c) 2009-2020, National Research Foundation (SARAO) # # Licensed under the BSD 3-Clause License (the "License"); you may not use # this file except in compliance with the License. You may obtain a copy diff --git a/katpoint/model.py b/katpoint/model.py index b7592c1..88f5fc1 100644 --- a/katpoint/model.py +++ b/katpoint/model.py @@ -1,5 +1,5 @@ ################################################################################ -# Copyright (c) 2009-2019, National Research Foundation (Square Kilometre Array) +# Copyright (c) 2009-2020, National Research Foundation (SARAO) # # Licensed under the BSD 3-Clause License (the "License"); you may not use # this file except in compliance with the License. You may obtain a copy diff --git a/katpoint/pointing.py b/katpoint/pointing.py index d647999..ca16212 100644 --- a/katpoint/pointing.py +++ b/katpoint/pointing.py @@ -1,5 +1,5 @@ ################################################################################ -# Copyright (c) 2009-2019, National Research Foundation (Square Kilometre Array) +# Copyright (c) 2009-2020, National Research Foundation (SARAO) # # Licensed under the BSD 3-Clause License (the "License"); you may not use # this file except in compliance with the License. You may obtain a copy diff --git a/katpoint/projection.py b/katpoint/projection.py index 9d188a6..5d48ebd 100644 --- a/katpoint/projection.py +++ b/katpoint/projection.py @@ -1,5 +1,5 @@ ################################################################################ -# Copyright (c) 2009-2019, National Research Foundation (Square Kilometre Array) +# Copyright (c) 2009-2020, National Research Foundation (SARAO) # # Licensed under the BSD 3-Clause License (the "License"); you may not use # this file except in compliance with the License. You may obtain a copy diff --git a/katpoint/refraction.py b/katpoint/refraction.py index c3e1d88..1318461 100644 --- a/katpoint/refraction.py +++ b/katpoint/refraction.py @@ -1,5 +1,5 @@ ################################################################################ -# Copyright (c) 2009-2019, National Research Foundation (Square Kilometre Array) +# Copyright (c) 2009-2020, National Research Foundation (SARAO) # # Licensed under the BSD 3-Clause License (the "License"); you may not use # this file except in compliance with the License. You may obtain a copy diff --git a/katpoint/stars.py b/katpoint/stars.py index 03a35f5..8ddd131 100644 --- a/katpoint/stars.py +++ b/katpoint/stars.py @@ -1,5 +1,5 @@ ################################################################################ -# Copyright (c) 2009-2019, National Research Foundation (Square Kilometre Array) +# Copyright (c) 2009-2020, National Research Foundation (SARAO) # # Licensed under the BSD 3-Clause License (the "License"); you may not use # this file except in compliance with the License. You may obtain a copy diff --git a/katpoint/target.py b/katpoint/target.py index 93b7b5a..854f95f 100644 --- a/katpoint/target.py +++ b/katpoint/target.py @@ -1,5 +1,5 @@ ################################################################################ -# Copyright (c) 2009-2019, National Research Foundation (Square Kilometre Array) +# Copyright (c) 2009-2020, National Research Foundation (SARAO) # # Licensed under the BSD 3-Clause License (the "License"); you may not use # this file except in compliance with the License. You may obtain a copy diff --git a/katpoint/test/__init__.py b/katpoint/test/__init__.py index 86ce79a..6be0f88 100644 --- a/katpoint/test/__init__.py +++ b/katpoint/test/__init__.py @@ -1,5 +1,5 @@ ################################################################################ -# Copyright (c) 2009-2019, National Research Foundation (Square Kilometre Array) +# Copyright (c) 2009-2020, National Research Foundation (SARAO) # # Licensed under the BSD 3-Clause License (the "License"); you may not use # this file except in compliance with the License. You may obtain a copy diff --git a/katpoint/test/aips_projection/build_module.sh b/katpoint/test/aips_projection/build_module.sh index 0d1e792..54ed6ce 100755 --- a/katpoint/test/aips_projection/build_module.sh +++ b/katpoint/test/aips_projection/build_module.sh @@ -1,7 +1,7 @@ #!/bin/bash ################################################################################ -# Copyright (c) 2009-2019, National Research Foundation (Square Kilometre Array) +# Copyright (c) 2009-2020, National Research Foundation (SARAO) # # Licensed under the BSD 3-Clause License (the "License"); you may not use # this file except in compliance with the License. You may obtain a copy diff --git a/katpoint/test/test_antenna.py b/katpoint/test/test_antenna.py index 9cc8835..a8e53dc 100644 --- a/katpoint/test/test_antenna.py +++ b/katpoint/test/test_antenna.py @@ -1,5 +1,5 @@ ################################################################################ -# Copyright (c) 2009-2019, National Research Foundation (Square Kilometre Array) +# Copyright (c) 2009-2020, National Research Foundation (SARAO) # # Licensed under the BSD 3-Clause License (the "License"); you may not use # this file except in compliance with the License. You may obtain a copy diff --git a/katpoint/test/test_body.py b/katpoint/test/test_body.py index 55864c3..19574a5 100644 --- a/katpoint/test/test_body.py +++ b/katpoint/test/test_body.py @@ -1,3 +1,19 @@ +################################################################################ +# Copyright (c) 2009-2020, National Research Foundation (SARAO) +# +# Licensed under the BSD 3-Clause License (the "License"); you may not use +# this file except in compliance with the License. You may obtain a copy +# of the License at +# +# https://opensource.org/licenses/BSD-3-Clause +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +################################################################################ + """Tests for the body module. The values in the comments are the results from the same tests run on the diff --git a/katpoint/test/test_catalogue.py b/katpoint/test/test_catalogue.py index 47b85f5..9b72243 100644 --- a/katpoint/test/test_catalogue.py +++ b/katpoint/test/test_catalogue.py @@ -1,5 +1,5 @@ ################################################################################ -# Copyright (c) 2009-2019, National Research Foundation (Square Kilometre Array) +# Copyright (c) 2009-2020, National Research Foundation (SARAO) # # Licensed under the BSD 3-Clause License (the "License"); you may not use # this file except in compliance with the License. You may obtain a copy diff --git a/katpoint/test/test_conversion.py b/katpoint/test/test_conversion.py index 0620f5b..d1aa849 100644 --- a/katpoint/test/test_conversion.py +++ b/katpoint/test/test_conversion.py @@ -1,5 +1,5 @@ ################################################################################ -# Copyright (c) 2009-2019, National Research Foundation (Square Kilometre Array) +# Copyright (c) 2009-2020, National Research Foundation (SARAO) # # Licensed under the BSD 3-Clause License (the "License"); you may not use # this file except in compliance with the License. You may obtain a copy diff --git a/katpoint/test/test_delay.py b/katpoint/test/test_delay.py index 7d3cff0..4c3586e 100644 --- a/katpoint/test/test_delay.py +++ b/katpoint/test/test_delay.py @@ -1,5 +1,5 @@ ################################################################################ -# Copyright (c) 2009-2019, National Research Foundation (Square Kilometre Array) +# Copyright (c) 2009-2020, National Research Foundation (SARAO) # # Licensed under the BSD 3-Clause License (the "License"); you may not use # this file except in compliance with the License. You may obtain a copy diff --git a/katpoint/test/test_flux.py b/katpoint/test/test_flux.py index a6fb456..483d90c 100644 --- a/katpoint/test/test_flux.py +++ b/katpoint/test/test_flux.py @@ -1,5 +1,5 @@ ################################################################################ -# Copyright (c) 2009-2019, National Research Foundation (Square Kilometre Array) +# Copyright (c) 2009-2020, National Research Foundation (SARAO) # # Licensed under the BSD 3-Clause License (the "License"); you may not use # this file except in compliance with the License. You may obtain a copy diff --git a/katpoint/test/test_model.py b/katpoint/test/test_model.py index 17c2a24..1bb8ba3 100644 --- a/katpoint/test/test_model.py +++ b/katpoint/test/test_model.py @@ -1,5 +1,5 @@ ################################################################################ -# Copyright (c) 2009-2019, National Research Foundation (Square Kilometre Array) +# Copyright (c) 2009-2020, National Research Foundation (SARAO) # # Licensed under the BSD 3-Clause License (the "License"); you may not use # this file except in compliance with the License. You may obtain a copy diff --git a/katpoint/test/test_pointing.py b/katpoint/test/test_pointing.py index f802544..68a4e3f 100644 --- a/katpoint/test/test_pointing.py +++ b/katpoint/test/test_pointing.py @@ -1,5 +1,5 @@ ################################################################################ -# Copyright (c) 2009-2019, National Research Foundation (Square Kilometre Array) +# Copyright (c) 2009-2020, National Research Foundation (SARAO) # # Licensed under the BSD 3-Clause License (the "License"); you may not use # this file except in compliance with the License. You may obtain a copy diff --git a/katpoint/test/test_projection.py b/katpoint/test/test_projection.py index e3a9e52..5a5a8ba 100644 --- a/katpoint/test/test_projection.py +++ b/katpoint/test/test_projection.py @@ -1,5 +1,5 @@ ################################################################################ -# Copyright (c) 2009-2019, National Research Foundation (Square Kilometre Array) +# Copyright (c) 2009-2020, National Research Foundation (SARAO) # # Licensed under the BSD 3-Clause License (the "License"); you may not use # this file except in compliance with the License. You may obtain a copy diff --git a/katpoint/test/test_refraction.py b/katpoint/test/test_refraction.py index 5d4273c..887bd32 100644 --- a/katpoint/test/test_refraction.py +++ b/katpoint/test/test_refraction.py @@ -1,5 +1,5 @@ ################################################################################ -# Copyright (c) 2009-2019, National Research Foundation (Square Kilometre Array) +# Copyright (c) 2009-2020, National Research Foundation (SARAO) # # Licensed under the BSD 3-Clause License (the "License"); you may not use # this file except in compliance with the License. You may obtain a copy diff --git a/katpoint/test/test_stars.py b/katpoint/test/test_stars.py index 4963f31..06cddda 100644 --- a/katpoint/test/test_stars.py +++ b/katpoint/test/test_stars.py @@ -1,3 +1,19 @@ +################################################################################ +# Copyright (c) 2009-2020, National Research Foundation (SARAO) +# +# Licensed under the BSD 3-Clause License (the "License"); you may not use +# this file except in compliance with the License. You may obtain a copy +# of the License at +# +# https://opensource.org/licenses/BSD-3-Clause +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +################################################################################ + """Tests for the stars module.""" import unittest diff --git a/katpoint/test/test_target.py b/katpoint/test/test_target.py index 70c081f..8670588 100644 --- a/katpoint/test/test_target.py +++ b/katpoint/test/test_target.py @@ -1,5 +1,5 @@ ################################################################################ -# Copyright (c) 2009-2019, National Research Foundation (Square Kilometre Array) +# Copyright (c) 2009-2020, National Research Foundation (SARAO) # # Licensed under the BSD 3-Clause License (the "License"); you may not use # this file except in compliance with the License. You may obtain a copy diff --git a/katpoint/test/test_timestamp.py b/katpoint/test/test_timestamp.py index bbc77a9..8d47a35 100644 --- a/katpoint/test/test_timestamp.py +++ b/katpoint/test/test_timestamp.py @@ -1,5 +1,5 @@ ################################################################################ -# Copyright (c) 2009-2019, National Research Foundation (Square Kilometre Array) +# Copyright (c) 2009-2020, National Research Foundation (SARAO) # # Licensed under the BSD 3-Clause License (the "License"); you may not use # this file except in compliance with the License. You may obtain a copy diff --git a/katpoint/timestamp.py b/katpoint/timestamp.py index 46cc354..ec49136 100644 --- a/katpoint/timestamp.py +++ b/katpoint/timestamp.py @@ -1,5 +1,5 @@ ################################################################################ -# Copyright (c) 2009-2019, National Research Foundation (Square Kilometre Array) +# Copyright (c) 2009-2020, National Research Foundation (SARAO) # # Licensed under the BSD 3-Clause License (the "License"); you may not use # this file except in compliance with the License. You may obtain a copy diff --git a/scripts/atca_calibrators.py b/scripts/atca_calibrators.py index 7877ce6..d0de094 100644 --- a/scripts/atca_calibrators.py +++ b/scripts/atca_calibrators.py @@ -1,7 +1,7 @@ #! /usr/bin/python ################################################################################ -# Copyright (c) 2009-2019, National Research Foundation (Square Kilometre Array) +# Copyright (c) 2009-2020, National Research Foundation (SARAO) # # Licensed under the BSD 3-Clause License (the "License"); you may not use # this file except in compliance with the License. You may obtain a copy diff --git a/scripts/bae_optical_pointing_sources.py b/scripts/bae_optical_pointing_sources.py index 88d7c2b..057eed6 100644 --- a/scripts/bae_optical_pointing_sources.py +++ b/scripts/bae_optical_pointing_sources.py @@ -1,7 +1,7 @@ #! /usr/bin/python ################################################################################ -# Copyright (c) 2009-2019, National Research Foundation (Square Kilometre Array) +# Copyright (c) 2009-2020, National Research Foundation (SARAO) # # Licensed under the BSD 3-Clause License (the "License"); you may not use # this file except in compliance with the License. You may obtain a copy diff --git a/scripts/kuehr1Jy_sources.py b/scripts/kuehr1Jy_sources.py index 5161aea..15689d2 100644 --- a/scripts/kuehr1Jy_sources.py +++ b/scripts/kuehr1Jy_sources.py @@ -1,7 +1,7 @@ #! /usr/bin/python ################################################################################ -# Copyright (c) 2009-2019, National Research Foundation (Square Kilometre Array) +# Copyright (c) 2009-2020, National Research Foundation (SARAO) # # Licensed under the BSD 3-Clause License (the "License"); you may not use # this file except in compliance with the License. You may obtain a copy diff --git a/scripts/merge_catalogues.py b/scripts/merge_catalogues.py index 36d4ff3..07bae1a 100644 --- a/scripts/merge_catalogues.py +++ b/scripts/merge_catalogues.py @@ -1,5 +1,5 @@ ################################################################################ -# Copyright (c) 2009-2019, National Research Foundation (Square Kilometre Array) +# Copyright (c) 2009-2020, National Research Foundation (SARAO) # # Licensed under the BSD 3-Clause License (the "License"); you may not use # this file except in compliance with the License. You may obtain a copy diff --git a/scripts/pkscat90_sources.py b/scripts/pkscat90_sources.py index d2aabea..67362e8 100644 --- a/scripts/pkscat90_sources.py +++ b/scripts/pkscat90_sources.py @@ -1,7 +1,7 @@ #! /usr/bin/python ################################################################################ -# Copyright (c) 2009-2019, National Research Foundation (Square Kilometre Array) +# Copyright (c) 2009-2020, National Research Foundation (SARAO) # # Licensed under the BSD 3-Clause License (the "License"); you may not use # this file except in compliance with the License. You may obtain a copy diff --git a/scripts/tabara_sources.py b/scripts/tabara_sources.py index 0a2a177..f5b503d 100644 --- a/scripts/tabara_sources.py +++ b/scripts/tabara_sources.py @@ -1,7 +1,7 @@ #! /usr/bin/python ################################################################################ -# Copyright (c) 2009-2019, National Research Foundation (Square Kilometre Array) +# Copyright (c) 2009-2020, National Research Foundation (SARAO) # # Licensed under the BSD 3-Clause License (the "License"); you may not use # this file except in compliance with the License. You may obtain a copy diff --git a/scripts/validate_targets.py b/scripts/validate_targets.py index 57ca3e3..2909f38 100644 --- a/scripts/validate_targets.py +++ b/scripts/validate_targets.py @@ -1,6 +1,6 @@ #!/usr/bin/env python ################################################################################ -# Copyright (c) 2009-2019, National Research Foundation (Square Kilometre Array) +# Copyright (c) 2009-2020, National Research Foundation (SARAO) # # Licensed under the BSD 3-Clause License (the "License"); you may not use # this file except in compliance with the License. You may obtain a copy diff --git a/setup.py b/setup.py index a5ee077..eac870e 100755 --- a/setup.py +++ b/setup.py @@ -1,7 +1,7 @@ #!/usr/bin/env python ################################################################################ -# Copyright (c) 2009-2019, National Research Foundation (Square Kilometre Array) +# Copyright (c) 2009-2020, National Research Foundation (SARAO) # # Licensed under the BSD 3-Clause License (the "License"); you may not use # this file except in compliance with the License. You may obtain a copy From e45475361effebd8624e87da35bf9678382a72e1 Mon Sep 17 00:00:00 2001 From: Ludwig Schwardt Date: Thu, 2 Jul 2020 23:42:13 +0200 Subject: [PATCH 004/122] Drop Python 2 support The minimum is now Python 3.5. Strip out the future incantations at the top of each file (easy peasy), and remove unittest2, basestring and the odd PY2 reference. Don't inherit from object. Import configparser and StringIO appropriately. Remove Python 2 deprecation warning. Fix up setup.py and requirements files. --- katpoint/__init__.py | 11 ----------- katpoint/antenna.py | 5 +---- katpoint/bodies.py | 6 +++--- katpoint/catalogue.py | 12 ++++-------- katpoint/conversion.py | 1 - katpoint/delay.py | 8 ++------ katpoint/ephem_extra.py | 4 +--- katpoint/flux.py | 7 ++----- katpoint/model.py | 21 +++++---------------- katpoint/pointing.py | 3 --- katpoint/projection.py | 2 -- katpoint/refraction.py | 5 +---- katpoint/target.py | 13 ++++--------- katpoint/test/test_antenna.py | 1 - katpoint/test/test_catalogue.py | 1 - katpoint/test/test_conversion.py | 1 - katpoint/test/test_delay.py | 6 +----- katpoint/test/test_flux.py | 3 +-- katpoint/test/test_model.py | 6 +----- katpoint/test/test_pointing.py | 1 - katpoint/test/test_projection.py | 1 - katpoint/test/test_refraction.py | 1 - katpoint/test/test_target.py | 1 - katpoint/test/test_timestamp.py | 1 - katpoint/timestamp.py | 8 ++------ requirements.txt | 7 ++++--- setup.py | 8 +++----- system-requirements.txt | 5 ++--- test-requirements.txt | 7 +------ 29 files changed, 38 insertions(+), 118 deletions(-) diff --git a/katpoint/__init__.py b/katpoint/__init__.py index 7dd8a73..bd6b6c7 100644 --- a/katpoint/__init__.py +++ b/katpoint/__init__.py @@ -25,13 +25,10 @@ and CASA. """ -from __future__ import print_function, division, absolute_import import logging as _logging import warnings as _warnings -import future.utils - from .target import Target, construct_azel_target, construct_radec_target, NonAsciiError from .antenna import Antenna from .timestamp import Timestamp @@ -73,14 +70,6 @@ def filter(self, record): logger = _logging.getLogger(__name__) logger.addHandler(_no_config_handler) -if future.utils.PY2: - _PY2_WARNING = ( - "Python 2 has reached End-of-Life, and a future version of katpoint " - "will remove support for it. Please update your scripts to Python 3 " - "as soon as possible." - ) - _warnings.warn(_PY2_WARNING, FutureWarning) - # BEGIN VERSION CHECK # Get package version when locally imported from repo or via -e develop install try: diff --git a/katpoint/antenna.py b/katpoint/antenna.py index 10a1c44..f0d5d10 100644 --- a/katpoint/antenna.py +++ b/katpoint/antenna.py @@ -21,9 +21,6 @@ and other parameters that affect pointing and delay calculations. """ -from __future__ import print_function, division, absolute_import -from builtins import object - import numpy as np import astropy.units as u from astropy.coordinates import Latitude, Longitude, EarthLocation @@ -40,7 +37,7 @@ # -------------------------------------------------------------------------------------------------- -class Antenna(object): +class Antenna: """An antenna that can point at a target. This is a wrapper around an Astropy earth location diff --git a/katpoint/bodies.py b/katpoint/bodies.py index 5650e72..8b8ce94 100644 --- a/katpoint/bodies.py +++ b/katpoint/bodies.py @@ -25,8 +25,8 @@ import copy import datetime -import numpy as np +import numpy as np import astropy.units as u from astropy.coordinates import solar_system_ephemeris, get_body, get_sun, get_moon from astropy.coordinates import CIRS, ICRS, SkyCoord, AltAz @@ -42,7 +42,7 @@ from .ephem_extra import angle_from_degrees -class Body(object): +class Body: """Base class for all Body classes. Attributes @@ -484,7 +484,7 @@ def compute(self, loc, date, pressure): self.radec = self.a_radec.transform_to(CIRS(obstime=date)) -class NullBody(object): +class NullBody: """Body with no position, used as a placeholder. This body has the expected methods of :class:`Body`, but always returns NaNs diff --git a/katpoint/catalogue.py b/katpoint/catalogue.py index 352b41f..9fde43f 100644 --- a/katpoint/catalogue.py +++ b/katpoint/catalogue.py @@ -16,10 +16,6 @@ """Target catalogue.""" -from __future__ import print_function, division, absolute_import -from builtins import object -from past.builtins import basestring - import logging from collections import defaultdict @@ -44,7 +40,7 @@ def _normalised(name): # -------------------------------------------------------------------------------------------------- -class Catalogue(object): +class Catalogue: """A searchable and filterable catalogue of targets. Overview @@ -434,10 +430,10 @@ def add(self, targets, tags=None): >>> cat2 = Catalogue() >>> cat2.add(cat.targets) """ - if isinstance(targets, basestring) or isinstance(targets, Target): + if isinstance(targets, str) or isinstance(targets, Target): targets = [targets] for target in targets: - if isinstance(target, basestring): + if isinstance(target, str): # Ignore strings starting with a hash (assumed to be comments) # or only containing whitespace if (len(target.strip()) == 0) or (target[0] == '#'): @@ -706,7 +702,7 @@ def iterfilter(self, tags=None, flux_limit_Jy=None, flux_freq_MHz=None, az_limit # First apply static criteria (tags, flux) which do not depend on timestamp if tag_filter: - if isinstance(tags, basestring): + if isinstance(tags, str): tags = tags.split() desired_tags = set([tag for tag in tags if tag[0] != '~']) undesired_tags = set([tag[1:] for tag in tags if tag[0] == '~']) diff --git a/katpoint/conversion.py b/katpoint/conversion.py index a6ab046..d326247 100644 --- a/katpoint/conversion.py +++ b/katpoint/conversion.py @@ -15,7 +15,6 @@ ################################################################################ """Coordinate conversions not found in PyEphem.""" -from __future__ import print_function, division, absolute_import import numpy as np diff --git a/katpoint/delay.py b/katpoint/delay.py index a6b9476..93dfb81 100644 --- a/katpoint/delay.py +++ b/katpoint/delay.py @@ -21,10 +21,6 @@ delay correction for a correlator. """ -from __future__ import print_function, division, absolute_import -from builtins import object, zip -from past.builtins import basestring - import logging import json @@ -93,7 +89,7 @@ def fromdelays(self, delays): self.fromlist(delays * self._speeds) -class DelayCorrection(object): +class DelayCorrection: """Calculate delay corrections for a set of correlator inputs / antennas. This uses delay models from multiple antennas connected to a correlator to @@ -138,7 +134,7 @@ class DelayCorrection(object): def __init__(self, ants, ref_ant=None, sky_centre_freq=0.0, extra_delay=None): # Unpack JSON-encoded description string - if isinstance(ants, basestring): + if isinstance(ants, str): try: descr = json.loads(ants) except ValueError: diff --git a/katpoint/ephem_extra.py b/katpoint/ephem_extra.py index 8926915..2e79c7d 100644 --- a/katpoint/ephem_extra.py +++ b/katpoint/ephem_extra.py @@ -15,8 +15,6 @@ ################################################################################ """Enhancements to PyEphem.""" -from __future__ import print_function, division, absolute_import -from past.builtins import basestring import numpy as np import astropy.units as u @@ -32,7 +30,7 @@ def is_iterable(x): """Checks if object is iterable (but not a string or 0-dimensional array).""" - return hasattr(x, '__iter__') and not isinstance(x, basestring) and \ + return hasattr(x, '__iter__') and not isinstance(x, str) and \ not (getattr(x, 'shape', None) == ()) diff --git a/katpoint/flux.py b/katpoint/flux.py index 95b73f6..df3b219 100644 --- a/katpoint/flux.py +++ b/katpoint/flux.py @@ -15,9 +15,6 @@ ################################################################################ """Flux density model.""" -from __future__ import print_function, division, absolute_import -from builtins import object -from past.builtins import basestring import warnings @@ -31,7 +28,7 @@ class FluxError(ValueError): pass -class FluxDensityModel(object): +class FluxDensityModel: """Spectral flux density model. This models the spectral flux density (or spectral energy distribtion - SED) @@ -101,7 +98,7 @@ class FluxDensityModel(object): def __init__(self, min_freq_MHz, max_freq_MHz=None, coefs=None): # If the first parameter is a description string, extract the relevant flux parameters from it - if isinstance(min_freq_MHz, basestring): + if isinstance(min_freq_MHz, str): # Cannot have other parameters if description string is given - this is a safety check if not (max_freq_MHz is None and coefs is None): raise ValueError("First parameter '%s' is description string - cannot have other parameters" % diff --git a/katpoint/model.py b/katpoint/model.py index 88f5fc1..a103a3e 100644 --- a/katpoint/model.py +++ b/katpoint/model.py @@ -20,21 +20,13 @@ saving and display of parameters. """ -from __future__ import print_function, division, absolute_import -import future.utils -from builtins import object, zip -from past.builtins import basestring - -try: - import ConfigParser as configparser # python2 -except ImportError: - import configparser # python3 +import configparser from collections import OrderedDict import numpy as np -class Parameter(object): +class Parameter: """Generic model parameter. This represents a single model parameter, bundling together its attributes @@ -100,7 +92,7 @@ class BadModelFile(Exception): pass -class Model(object): +class Model: """Base class for models (e.g. pointing and delay models). The base class handles the construction / loading, saving, display and @@ -248,10 +240,7 @@ def fromfile(self, file_like): File-like object with readline() method representing config file """ defaults = dict((p.name, p._to_str(p.default_value)) for p in self) - if future.utils.PY2: - cfg = configparser.ConfigParser(defaults) - else: - cfg = configparser.ConfigParser(defaults, inline_comment_prefixes=(';', '#')) + cfg = configparser.ConfigParser(defaults, inline_comment_prefixes=(';', '#')) try: cfg.read_file(file_like) if cfg.sections() != ['header', 'params']: @@ -289,7 +278,7 @@ def set(self, model=None): model.__class__.__name__)) self.fromlist(model.values()) self.header = dict(model.header) - elif isinstance(model, basestring): + elif isinstance(model, str): self.fromstring(model) else: array = np.atleast_1d(model) diff --git a/katpoint/pointing.py b/katpoint/pointing.py index ca16212..a967e54 100644 --- a/katpoint/pointing.py +++ b/katpoint/pointing.py @@ -19,9 +19,6 @@ This implements a pointing model for a non-ideal antenna mount. """ -from __future__ import print_function, division, absolute_import -from builtins import range - import logging import numpy as np diff --git a/katpoint/projection.py b/katpoint/projection.py index 5d48ebd..1c1cf79 100644 --- a/katpoint/projection.py +++ b/katpoint/projection.py @@ -125,8 +125,6 @@ FITS. II," Astronomy & Astrophysics, vol. 395, pp. 1077-1122, 2002. """ -from __future__ import print_function, division, absolute_import - import numpy as np # -------------------------------------------------------------------------------------------------- diff --git a/katpoint/refraction.py b/katpoint/refraction.py index 1318461..82419eb 100644 --- a/katpoint/refraction.py +++ b/katpoint/refraction.py @@ -19,9 +19,6 @@ This implements correction for refractive bending in the atmosphere. """ -from __future__ import print_function, division, absolute_import -from builtins import object, range - import logging import numpy as np @@ -107,7 +104,7 @@ def refraction_offset_vlbi(el, temperature_C, pressure_hPa, humidity_percent): return deg2rad(bphi * sn - aphi) -class RefractionCorrection(object): +class RefractionCorrection: """Correct pointing for refractive bending in atmosphere. This uses the specified refraction model to calculate a correction to a diff --git a/katpoint/target.py b/katpoint/target.py index 854f95f..4184de5 100644 --- a/katpoint/target.py +++ b/katpoint/target.py @@ -16,12 +16,7 @@ """Target object used for pointing and flux density calculation.""" -from __future__ import print_function, division, absolute_import -from builtins import object, range -from past.builtins import basestring - import numpy as np - import astropy.units as u from astropy.coordinates import SkyCoord # High-level coordinates from astropy.coordinates import ICRS, Galactic, FK4, FK5 # Low-level frames @@ -43,7 +38,7 @@ class NonAsciiError(ValueError): pass -class Target(object): +class Target: """A target which can be pointed at by an antenna. This is a wrapper around a PyEphem :class:`ephem.Body` that adds flux @@ -125,7 +120,7 @@ def __init__(self, body, tags=None, aliases=None, flux_model=None, antenna=None, if isinstance(body, Target): body = body.description # If the first parameter is a description string, extract the relevant target parameters from it - if isinstance(body, basestring): + if isinstance(body, str): body, tags, aliases, flux_model = construct_target_params(body) self.body = body self.name = self.body.name @@ -309,7 +304,7 @@ def add_tags(self, tags): """ if tags is None: tags = [] - if isinstance(tags, basestring): + if isinstance(tags, str): tags = [tags] for tag_str in tags: for tag in tag_str.split(): @@ -1180,7 +1175,7 @@ def construct_radec_target(ra, dec): """ body = FixedBody() # First try to interpret the string as decimal degrees - if isinstance(ra, basestring): + if isinstance(ra, str): try: ra = deg2rad(float(ra)) except ValueError: diff --git a/katpoint/test/test_antenna.py b/katpoint/test/test_antenna.py index a8e53dc..c56a79b 100644 --- a/katpoint/test/test_antenna.py +++ b/katpoint/test/test_antenna.py @@ -15,7 +15,6 @@ ################################################################################ """Tests for the antenna module.""" -from __future__ import print_function, division, absolute_import import unittest import time diff --git a/katpoint/test/test_catalogue.py b/katpoint/test/test_catalogue.py index 9b72243..185244c 100644 --- a/katpoint/test/test_catalogue.py +++ b/katpoint/test/test_catalogue.py @@ -15,7 +15,6 @@ ################################################################################ """Tests for the catalogue module.""" -from __future__ import print_function, division, absolute_import import unittest import time diff --git a/katpoint/test/test_conversion.py b/katpoint/test/test_conversion.py index d1aa849..8f60af6 100644 --- a/katpoint/test/test_conversion.py +++ b/katpoint/test/test_conversion.py @@ -15,7 +15,6 @@ ################################################################################ """Tests for the conversion module.""" -from __future__ import print_function, division, absolute_import import unittest diff --git a/katpoint/test/test_delay.py b/katpoint/test/test_delay.py index 4c3586e..4a7a35e 100644 --- a/katpoint/test/test_delay.py +++ b/katpoint/test/test_delay.py @@ -15,14 +15,10 @@ ################################################################################ """Tests for the model module.""" -from __future__ import print_function, division, absolute_import import json import unittest -try: - from StringIO import StringIO # python2 -except ImportError: - from io import StringIO # python3 +from io import StringIO import numpy as np import astropy.units as u diff --git a/katpoint/test/test_flux.py b/katpoint/test/test_flux.py index 483d90c..e9ca768 100644 --- a/katpoint/test/test_flux.py +++ b/katpoint/test/test_flux.py @@ -15,9 +15,8 @@ ################################################################################ """Tests for the flux module.""" -from __future__ import print_function, division, absolute_import -import unittest2 as unittest +import unittest import numpy as np diff --git a/katpoint/test/test_model.py b/katpoint/test/test_model.py index 1bb8ba3..958d1cf 100644 --- a/katpoint/test/test_model.py +++ b/katpoint/test/test_model.py @@ -15,13 +15,9 @@ ################################################################################ """Tests for the model module.""" -from __future__ import print_function, division, absolute_import import unittest -try: - from StringIO import StringIO # python2 -except ImportError: - from io import StringIO # python3 +from io import StringIO import katpoint diff --git a/katpoint/test/test_pointing.py b/katpoint/test/test_pointing.py index 68a4e3f..3f37980 100644 --- a/katpoint/test/test_pointing.py +++ b/katpoint/test/test_pointing.py @@ -15,7 +15,6 @@ ################################################################################ """Tests for the pointing module.""" -from __future__ import print_function, division, absolute_import import unittest diff --git a/katpoint/test/test_projection.py b/katpoint/test/test_projection.py index 5a5a8ba..fc65da8 100644 --- a/katpoint/test/test_projection.py +++ b/katpoint/test/test_projection.py @@ -15,7 +15,6 @@ ################################################################################ """Tests for the projection module.""" -from __future__ import print_function, division, absolute_import import unittest diff --git a/katpoint/test/test_refraction.py b/katpoint/test/test_refraction.py index 887bd32..dabae63 100644 --- a/katpoint/test/test_refraction.py +++ b/katpoint/test/test_refraction.py @@ -15,7 +15,6 @@ ################################################################################ """Tests for the refraction module.""" -from __future__ import print_function, division, absolute_import import unittest diff --git a/katpoint/test/test_target.py b/katpoint/test/test_target.py index 8670588..e41f8da 100644 --- a/katpoint/test/test_target.py +++ b/katpoint/test/test_target.py @@ -15,7 +15,6 @@ ################################################################################ """Tests for the target module.""" -from __future__ import print_function, division, absolute_import import unittest import time diff --git a/katpoint/test/test_timestamp.py b/katpoint/test/test_timestamp.py index 8d47a35..6a6d02f 100644 --- a/katpoint/test/test_timestamp.py +++ b/katpoint/test/test_timestamp.py @@ -15,7 +15,6 @@ ################################################################################ """Tests for the timestamp module.""" -from __future__ import print_function, division, absolute_import import unittest diff --git a/katpoint/timestamp.py b/katpoint/timestamp.py index ec49136..977f350 100644 --- a/katpoint/timestamp.py +++ b/katpoint/timestamp.py @@ -15,13 +15,9 @@ ################################################################################ """A Timestamp object.""" -from __future__ import print_function, division, absolute_import -from builtins import object -from past.builtins import basestring import time import math - from functools import total_ordering import numpy as np @@ -29,7 +25,7 @@ @total_ordering -class Timestamp(object): +class Timestamp: """Basic representation of time, in UTC seconds since Unix epoch. This is loosely based on :class:`ephem.Date`. Its base representation @@ -62,7 +58,7 @@ class Timestamp(object): """ def __init__(self, timestamp=None): - if isinstance(timestamp, basestring): + if isinstance(timestamp, str): try: timestamp = timestamp.strip().replace('/', '-') timestamp = Time(decode(timestamp)) diff --git a/requirements.txt b/requirements.txt index bf44234..c32c2dc 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,6 @@ -future +astropy numpy -astroppy -sgp4 pyorbital +requests # from pyorbital +scipy # from pyorbital +sgp4 diff --git a/setup.py b/setup.py index eac870e..876db91 100755 --- a/setup.py +++ b/setup.py @@ -53,20 +53,18 @@ platforms=["OS Independent"], keywords="meerkat ska", zip_safe=False, - python_requires='>=2.7, !=3.0.*, !=3.1.*, !=3.2.*, <4', + python_requires='>=3.5, <4', setup_requires=['katversion'], use_katversion=True, test_suite="nose.collector", install_requires=[ - "future", - "numpy", "astropy", - "sgp4", + "numpy", "pyorbital", + "sgp4", ], tests_require=[ "nose", "coverage", "nosexcover", - "unittest2", ]) diff --git a/system-requirements.txt b/system-requirements.txt index d85daa4..c88d4d5 100644 --- a/system-requirements.txt +++ b/system-requirements.txt @@ -1,7 +1,6 @@ -nose coverage +nose nosexcover -virtualenv -unittest2 numpy +virtualenv diff --git a/test-requirements.txt b/test-requirements.txt index 231bf7d..33f4945 100644 --- a/test-requirements.txt +++ b/test-requirements.txt @@ -1,7 +1,2 @@ -argparse # via unittest2 -nose coverage -linecache2 # via traceback2 -six # via unittest2 -traceback2 # via unittest2 -unittest2 +nose From 7a39da3cada218c4cf706b0cb656b49a5fbdb535 Mon Sep 17 00:00:00 2001 From: Ludwig Schwardt Date: Fri, 3 Jul 2020 00:19:06 +0200 Subject: [PATCH 005/122] Remove dangling and broken code The now() function is not used and broken, so chuck it. The galactic() method is not properly tested and contains some dud code, so patch it up as far as possible. --- katpoint/target.py | 5 +++-- katpoint/timestamp.py | 6 ------ 2 files changed, 3 insertions(+), 8 deletions(-) diff --git a/katpoint/target.py b/katpoint/target.py index 4184de5..d6bb551 100644 --- a/katpoint/target.py +++ b/katpoint/target.py @@ -465,12 +465,13 @@ def galactic(self, timestamp=None, antenna=None): if self.body_type == 'gal': gal = self.body._radec.transform_to(Galactic) if is_iterable(timestamp): - return np.tile(gal.l, len(timestamp)) + return np.tile(gal.l, len(timestamp)), np.tile(gal.b, len(timestamp)) else: return gal radec = self.astrometric_radec(timestamp, antenna) if is_iterable(radec): - lb = np.array([SkyCoord(ra[n], dec[n], frame=ICRS).transform_to(Galactic) for n in range(len(radec))]) + lb = np.array([SkyCoord(radec[n], frame=ICRS).transform_to(Galactic) + for n in range(len(radec))]) return np.array([g.l for g in lb]), np.array([g.b for g in lb]) else: gal = SkyCoord(radec, frame=ICRS).transform_to(Galactic) diff --git a/katpoint/timestamp.py b/katpoint/timestamp.py index 977f350..b94b8fe 100644 --- a/katpoint/timestamp.py +++ b/katpoint/timestamp.py @@ -236,9 +236,3 @@ def decode(s): d = time.localtime(u) return time.strftime('%Y-%m-%d %H:%M:%S', d) + f - - -def now(): - """ Create a Date representing 'now' - """ - return Date(Time.now().mjd - _djd) From 14a37bf900e18ee25fef49d35881d4752671380c Mon Sep 17 00:00:00 2001 From: Ludwig Schwardt Date: Fri, 3 Jul 2020 11:14:04 +0000 Subject: [PATCH 006/122] Apply 1 suggestion(s) to 1 file(s) --- scripts/psrcat_sources.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/psrcat_sources.py b/scripts/psrcat_sources.py index 16f75bd..880939f 100644 --- a/scripts/psrcat_sources.py +++ b/scripts/psrcat_sources.py @@ -1,4 +1,4 @@ -# /usr/bin/env python +#!/usr/bin/env python # # Tool that converts the ATNF PSRCAT database into a katpoint Catalogue. # From 1e73d255cb2dcb859f16255395f7b69bc7608592 Mon Sep 17 00:00:00 2001 From: Ludwig Schwardt Date: Fri, 3 Jul 2020 13:32:26 +0200 Subject: [PATCH 007/122] Fix docstring to be PEP-257 compliant --- katpoint/timestamp.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/katpoint/timestamp.py b/katpoint/timestamp.py index b94b8fe..6b51ebd 100644 --- a/katpoint/timestamp.py +++ b/katpoint/timestamp.py @@ -196,8 +196,7 @@ def to_mjd(self): def decode(s): - """Decodes a date string - """ + """Decode a date string like PyEphem does.""" # Look for a dot dot = s.find('.') if dot > 0: From e616cf0910062997d8a8e1b3f111a678537222fe Mon Sep 17 00:00:00 2001 From: Ludwig Schwardt Date: Fri, 3 Jul 2020 13:59:00 +0200 Subject: [PATCH 008/122] Switch from nose to pytest Also drop unittest / unittest2 and clear out `__init__.py`. Test classes now only have a trivial base class. Rename the setUp method to `setup` so that pytest can pick it up. Use standard asserts as far as possible, as espoused by pytest. Use pytest to check exceptions and warnings. Use np.testing.assert_allclose instead of unittest.TestCase.assertAlmostEqual, with rtol=0.0 and atol=0.5 * 10 ** (-places). Use pytest decorator to skip tests. Fix test_stars (it wasn't even discoverable or pulled in via `__init__` before). Fix setup.py and test-requirements.txt. --- katpoint/test/__init__.py | 64 --------------- katpoint/test/test_antenna.py | 38 ++++----- katpoint/test/test_body.py | 117 ++++++++++++++-------------- katpoint/test/test_catalogue.py | 130 ++++++++++++++++--------------- katpoint/test/test_conversion.py | 10 +-- katpoint/test/test_delay.py | 61 ++++++++------- katpoint/test/test_flux.py | 57 ++++++++------ katpoint/test/test_model.py | 41 +++++----- katpoint/test/test_pointing.py | 29 +++---- katpoint/test/test_projection.py | 109 +++++++++++++------------- katpoint/test/test_refraction.py | 16 ++-- katpoint/test/test_stars.py | 44 +++++------ katpoint/test/test_target.py | 55 +++++++------ katpoint/test/test_timestamp.py | 47 +++++------ setup.py | 5 +- test-requirements.txt | 4 +- 16 files changed, 391 insertions(+), 436 deletions(-) diff --git a/katpoint/test/__init__.py b/katpoint/test/__init__.py index 6be0f88..e69de29 100644 --- a/katpoint/test/__init__.py +++ b/katpoint/test/__init__.py @@ -1,64 +0,0 @@ -################################################################################ -# Copyright (c) 2009-2020, National Research Foundation (SARAO) -# -# Licensed under the BSD 3-Clause License (the "License"); you may not use -# this file except in compliance with the License. You may obtain a copy -# of the License at -# -# https://opensource.org/licenses/BSD-3-Clause -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -################################################################################ - -"""Unit test suite for katpoint.""" - -import logging -import sys -import unittest - -from katpoint.test import test_target -from katpoint.test import test_antenna -from katpoint.test import test_catalogue -from katpoint.test import test_projection -from katpoint.test import test_timestamp -from katpoint.test import test_flux -from katpoint.test import test_conversion -from katpoint.test import test_pointing -from katpoint.test import test_refraction -from katpoint.test import test_delay -from katpoint.test import test_body -from katpoint.test import test_stars - -# Enable verbose logging to stdout for katpoint module - see output via nosetests -s flag -logger = logging.getLogger("katpoint") -logger.setLevel(logging.DEBUG) -ch = logging.StreamHandler(sys.stdout) -ch.setLevel(logging.DEBUG) -ch.setFormatter(logging.Formatter("LOG: %(name)s %(levelname)s %(message)s")) -logger.addHandler(ch) - - -def suite(): - loader = unittest.TestLoader() - testsuite = unittest.TestSuite() - testsuite.addTests(loader.loadTestsFromModule(test_target)) - testsuite.addTests(loader.loadTestsFromModule(test_antenna)) - testsuite.addTests(loader.loadTestsFromModule(test_catalogue)) - testsuite.addTests(loader.loadTestsFromModule(test_projection)) - testsuite.addTests(loader.loadTestsFromModule(test_timestamp)) - testsuite.addTests(loader.loadTestsFromModule(test_flux)) - testsuite.addTests(loader.loadTestsFromModule(test_conversion)) - testsuite.addTests(loader.loadTestsFromModule(test_pointing)) - testsuite.addTests(loader.loadTestsFromModule(test_refraction)) - testsuite.addTests(loader.loadTestsFromModule(test_delay)) - testsuite.addTests(loader.loadTestsFromModule(test_body)) - testsuite.addTests(loader.loadTestsFromModule(test_stars)) - return testsuite - - -if __name__ == '__main__': - unittest.main(defaultTest='suite') diff --git a/katpoint/test/test_antenna.py b/katpoint/test/test_antenna.py index c56a79b..ff15d95 100644 --- a/katpoint/test/test_antenna.py +++ b/katpoint/test/test_antenna.py @@ -16,11 +16,11 @@ """Tests for the antenna module.""" -import unittest import time import pickle import numpy as np +import pytest import katpoint @@ -31,10 +31,10 @@ def primary_angle(x): np.testing.assert_almost_equal(primary_angle(x - y), np.zeros(np.shape(x)), decimal=decimal) -class TestAntenna(unittest.TestCase): +class TestAntenna: """Test :class:`katpoint.antenna.Antenna`.""" - def setUp(self): + def setup(self): self.valid_antennas = [ 'XDM, -25:53:23.0, 27:41:03.0, 1406.1086, 15.0', 'FF1, -30:43:17.3, 21:24:38.5, 1038.0, 12.0, 18.4 -8.7 0.0', @@ -54,28 +54,30 @@ def test_construct_antenna(self): for descr in valid_strings: ant = katpoint.Antenna(descr) print('%s %s' % (str(ant), repr(ant))) - self.assertEqual(descr, ant.description, 'Antenna description differs from original string') - self.assertEqual(ant.description, ant.format_katcp(), 'Antenna description differs from KATCP format') + assert descr == ant.description, 'Antenna description differs from original string' + assert ant.description == ant.format_katcp(), 'Antenna description differs from KATCP format' for descr in self.invalid_antennas: - self.assertRaises(ValueError, katpoint.Antenna, descr) + with pytest.raises(ValueError): + katpoint.Antenna(descr) descr = valid_antennas[0].description - self.assertEqual(descr, katpoint.Antenna(*descr.split(', ')).description) - self.assertRaises(ValueError, katpoint.Antenna, descr, *descr.split(', ')[1:]) + assert descr == katpoint.Antenna(*descr.split(', ')).description + with pytest.raises(ValueError): + katpoint.Antenna(descr, *descr.split(', ')[1:]) # Check that description string updates when object is updated a1 = katpoint.Antenna('FF1, -30:43:17.3, 21:24:38.5, 1038.0, 12.0, 18.4 -8.7 0.0') a2 = katpoint.Antenna('FF2, -30:43:17.3, 21:24:38.5, 1038.0, 13.0, 18.4 -8.7 0.0, 0.1, 1.22') - self.assertNotEqual(a1, a2, 'Antennas should be inequal') + assert a1 != a2, 'Antennas should be inequal' a1.name = 'FF2' a1.diameter = 13.0 a1.pointing_model = katpoint.PointingModel('0.1') a1.beamwidth = 1.22 - self.assertEqual(a1.description, a2.description, 'Antenna description string not updated') - self.assertEqual(a1, a2.description, 'Antenna not equal to description string') - self.assertEqual(a1, a2, 'Antennas not equal') - self.assertEqual(a1, katpoint.Antenna(a2), 'Construction with antenna object failed') - self.assertEqual(a1, pickle.loads(pickle.dumps(a1)), 'Pickling failed') + assert a1.description == a2.description, 'Antenna description string not updated' + assert a1 == a2.description, 'Antenna not equal to description string' + assert a1 == a2, 'Antennas not equal' + assert a1 == katpoint.Antenna(a2), 'Construction with antenna object failed' + assert a1 == pickle.loads(pickle.dumps(a1)), 'Pickling failed' try: - self.assertEqual(hash(a1), hash(a2), 'Antenna hashes not equal') + assert hash(a1) == hash(a2), 'Antenna hashes not equal' except TypeError: self.fail('Antenna object not hashable') @@ -85,8 +87,7 @@ def test_local_sidereal_time(self): utc_secs = time.mktime(time.strptime(self.timestamp, '%Y/%m/%d %H:%M:%S')) - time.timezone sid1 = ant.local_sidereal_time(self.timestamp) sid2 = ant.local_sidereal_time(utc_secs) - self.assertAlmostEqual(sid1.rad, sid2.rad, places=10, - msg='Sidereal time differs for float and date/time string') + assert sid1 == sid2, 'Sidereal time differs for float and date/time string' sid3 = ant.local_sidereal_time([self.timestamp, self.timestamp]) sid4 = ant.local_sidereal_time([utc_secs, utc_secs]) assert_angles_almost_equal(np.array([a.rad for a in sid3]), @@ -95,5 +96,4 @@ def test_local_sidereal_time(self): def test_array_reference_antenna(self): ant = katpoint.Antenna(self.valid_antennas[2]) ref_ant = ant.array_reference_antenna() - self.assertEqual(ref_ant.description, - 'array, -30:43:17.3, 21:24:38.5, 1038, 12.0, , , 1.16') + assert ref_ant.description == 'array, -30:43:17.3, 21:24:38.5, 1038, 12.0, , , 1.16' diff --git a/katpoint/test/test_body.py b/katpoint/test/test_body.py index 19574a5..21937e1 100644 --- a/katpoint/test/test_body.py +++ b/katpoint/test/test_body.py @@ -21,9 +21,8 @@ """ -import unittest - import numpy as np +from numpy.testing import assert_allclose import astropy.units as u from astropy.coordinates import SkyCoord, ICRS, EarthLocation, Latitude, Longitude from astropy.time import Time @@ -31,7 +30,7 @@ from katpoint.bodies import FixedBody, Sun, Moon, Mars, readtle -class TestFixedBody(unittest.TestCase): +class TestFixedBody: """Test for the FixedBody class.""" def test_compute(self): @@ -46,12 +45,12 @@ def test_compute(self): body._radec = SkyCoord(ra=ra, dec=dec, frame=ICRS) body.compute(EarthLocation(lat=lat, lon=lon, height=0.0), date, 0.0) - self.assertEqual(body.a_radec.ra.to_string(sep=':', unit=u.hour), '10:10:40.123') - self.assertEqual(body.a_radec.dec.to_string(sep=':'), '40:20:50.567') + assert body.a_radec.ra.to_string(sep=':', unit=u.hour) == '10:10:40.123' + assert body.a_radec.dec.to_string(sep=':') == '40:20:50.567' # 326:05:54.8 51:21:18.5 - self.assertEqual(body.altaz.az.to_string(sep=':'), '326:05:57.541') - self.assertEqual(body.altaz.alt.to_string(sep=':'), '51:21:20.0119') + assert body.altaz.az.to_string(sep=':') == '326:05:57.541' + assert body.altaz.alt.to_string(sep=':') == '51:21:20.0119' def test_planet(self): lat = Latitude('10:00:00.000', unit=u.deg) @@ -62,8 +61,8 @@ def test_planet(self): body.compute(EarthLocation(lat=lat, lon=lon, height=0.0), date, 0.0) # '118:10:06.1' '27:23:13.3' - self.assertEqual(body.altaz.az.to_string(sep=':'), '118:10:05.1129') - self.assertEqual(body.altaz.alt.to_string(sep=':'), '27:23:12.8499') + assert body.altaz.az.to_string(sep=':') == '118:10:05.1129' + assert body.altaz.alt.to_string(sep=':') == '27:23:12.8499' def test_moon(self): lat = Latitude('10:00:00.000', unit=u.deg) @@ -74,8 +73,8 @@ def test_moon(self): body.compute(EarthLocation(lat=lat, lon=lon, height=0.0), date, 0.0) # 127:15:23.6 60:05:13.7' - self.assertEqual(body.altaz.az.to_string(sep=':'), '127:15:17.1381') - self.assertEqual(body.altaz.alt.to_string(sep=':'), '60:05:10.2438') + assert body.altaz.az.to_string(sep=':') == '127:15:17.1381' + assert body.altaz.alt.to_string(sep=':') == '60:05:10.2438' def test_sun(self): lat = Latitude('10:00:00.000', unit=u.deg) @@ -86,8 +85,8 @@ def test_sun(self): body.compute(EarthLocation(lat=lat, lon=lon, height=0.0), date, 0.0) # 234:53:20.8 '31:38:09.4' - self.assertEqual(body.altaz.az.to_string(sep=':'), '234:53:19.4835') - self.assertEqual(body.altaz.alt.to_string(sep=':'), '31:38:11.412') + assert body.altaz.az.to_string(sep=':') == '234:53:19.4835' + assert body.altaz.alt.to_string(sep=':') == '31:38:11.412' def test_earth_satellite(self): name = ' GPS BIIA-21 (PRN 09) ' @@ -97,57 +96,57 @@ def test_earth_satellite(self): # Check that the EarthSatellite object has the expect attribute # values. - self.assertEqual(str(sat._epoch), '2019-09-23 07:45:35.842') - self.assertEqual(sat._inc, np.deg2rad(55.4408)) - self.assertEqual(sat._raan, np.deg2rad(61.3790)) - self.assertEqual(sat._e, 0.0191986) - self.assertEqual(sat._ap, np.deg2rad(78.1802)) - self.assertEqual(sat._M, np.deg2rad(283.9935)) - self.assertEqual(sat._n, 2.0056172) - self.assertEqual(sat._decay, 1.2e-07) - self.assertEqual(sat._orbit, 10428) - self.assertEqual(sat._drag, 1.e-04) + assert str(sat._epoch) == '2019-09-23 07:45:35.842' + assert sat._inc == np.deg2rad(55.4408) + assert sat._raan == np.deg2rad(61.3790) + assert sat._e == 0.0191986 + assert sat._ap == np.deg2rad(78.1802) + assert sat._M == np.deg2rad(283.9935) + assert sat._n == 2.0056172 + assert sat._decay == 1.2e-07 + assert sat._orbit == 10428 + assert sat._drag == 1.e-04 # This is xephem database record that pyephem generates xephem = ' GPS BIIA-21 (PRN 09) ,E,9/23.32333151/2019| 6/15.3242/2019| 1/1.32422/2020,' \ '55.4408,61.379002,0.0191986,78.180199,283.9935,2.0056172,1.2e-07,10428,9.9999997e-05' rec = sat.writedb() - self.assertEqual(rec.split(',')[0], xephem.split(',')[0]) - self.assertEqual(rec.split(',')[1], xephem.split(',')[1]) - - self.assertEqual(rec.split(',')[2].split('|')[0].split('/')[0], - xephem.split(',')[2].split('|')[0].split('/')[0]) - self.assertAlmostEqual(float(rec.split(',')[2].split('|')[0].split('/')[1]), - float(xephem.split(',')[2].split('|')[0].split('/')[1])) - self.assertEqual(rec.split(',')[2].split('|')[0].split('/')[2], - xephem.split(',')[2].split('|')[0].split('/')[2]) - - self.assertEqual(rec.split(',')[2].split('|')[1].split('/')[0], - xephem.split(',')[2].split('|')[1].split('/')[0]) - self.assertAlmostEqual(float(rec.split(',')[2].split('|')[1].split('/')[1]), - float(xephem.split(',')[2].split('|')[1].split('/')[1]), places=2) - self.assertEqual(rec.split(',')[2].split('|')[1].split('/')[2], - xephem.split(',')[2].split('|')[1].split('/')[2]) - - self.assertEqual(rec.split(',')[2].split('|')[2].split('/')[0], - xephem.split(',')[2].split('|')[2].split('/')[0]) - self.assertAlmostEqual(float(rec.split(',')[2].split('|')[2].split('/')[1]), - float(xephem.split(',')[2].split('|')[2].split('/')[1]), places=2) - self.assertEqual(rec.split(',')[2].split('|')[2].split('/')[2], - xephem.split(',')[2].split('|')[2].split('/')[2]) - - self.assertEqual(rec.split(',')[3], xephem.split(',')[3]) + assert rec.split(',')[0] == xephem.split(',')[0] + assert rec.split(',')[1] == xephem.split(',')[1] + + assert (rec.split(',')[2].split('|')[0].split('/')[0] + == xephem.split(',')[2].split('|')[0].split('/')[0]) + assert_allclose(float(rec.split(',')[2].split('|')[0].split('/')[1]), + float(xephem.split(',')[2].split('|')[0].split('/')[1]), rtol=0, atol=0.5e-7) + assert (rec.split(',')[2].split('|')[0].split('/')[2] + == xephem.split(',')[2].split('|')[0].split('/')[2]) + + assert (rec.split(',')[2].split('|')[1].split('/')[0] + == xephem.split(',')[2].split('|')[1].split('/')[0]) + assert_allclose(float(rec.split(',')[2].split('|')[1].split('/')[1]), + float(xephem.split(',')[2].split('|')[1].split('/')[1]), rtol=0, atol=0.5e-2) + assert (rec.split(',')[2].split('|')[1].split('/')[2] + == xephem.split(',')[2].split('|')[1].split('/')[2]) + + assert (rec.split(',')[2].split('|')[2].split('/')[0] + == xephem.split(',')[2].split('|')[2].split('/')[0]) + assert_allclose(float(rec.split(',')[2].split('|')[2].split('/')[1]), + float(xephem.split(',')[2].split('|')[2].split('/')[1]), rtol=0, atol=0.5e-2) + assert (rec.split(',')[2].split('|')[2].split('/')[2] + == xephem.split(',')[2].split('|')[2].split('/')[2]) + + assert rec.split(',')[3] == xephem.split(',')[3] # pyephem adds spurious precision to these 3 fields - self.assertEqual(rec.split(',')[4], xephem.split(',')[4][:6]) - self.assertEqual(rec.split(',')[5][:7], xephem.split(',')[5][:7]) - self.assertEqual(rec.split(',')[6], xephem.split(',')[6][:5]) + assert rec.split(',')[4] == xephem.split(',')[4][:6] + assert rec.split(',')[5][:7] == xephem.split(',')[5][:7] + assert rec.split(',')[6] == xephem.split(',')[6][:5] - self.assertEqual(rec.split(',')[7], xephem.split(',')[7]) - self.assertEqual(rec.split(',')[8], xephem.split(',')[8]) - self.assertEqual(rec.split(',')[9], xephem.split(',')[9]) - self.assertEqual(rec.split(',')[10], xephem.split(',')[10]) + assert rec.split(',')[7] == xephem.split(',')[7] + assert rec.split(',')[8] == xephem.split(',')[8] + assert rec.split(',')[9] == xephem.split(',')[9] + assert rec.split(',')[10] == xephem.split(',')[10] # Test compute lat = Latitude('10:00:00.000', unit=u.deg) @@ -157,9 +156,9 @@ def test_earth_satellite(self): sat.compute(EarthLocation(lat=lat, lon=lon, height=elevation), date, 0.0) # 3:32:59.21' '-2:04:36.3' - self.assertEqual(sat.a_radec.ra.to_string(sep=':', unit=u.hour), '3:32:56.7813') - self.assertEqual(sat.a_radec.dec.to_string(sep=':'), '-2:04:35.4329') + assert sat.a_radec.ra.to_string(sep=':', unit=u.hour) == '3:32:56.7813' + assert sat.a_radec.dec.to_string(sep=':') == '-2:04:35.4329' # 280:32:07.2 -54:06:14.4 - self.assertEqual(sat.altaz.az.to_string(sep=':'), '280:32:29.675') - self.assertEqual(sat.altaz.alt.to_string(sep=':'), '-54:06:50.7456') + assert sat.altaz.az.to_string(sep=':') == '280:32:29.675' + assert sat.altaz.alt.to_string(sep=':') == '-54:06:50.7456' diff --git a/katpoint/test/test_catalogue.py b/katpoint/test/test_catalogue.py index 185244c..e644ce5 100644 --- a/katpoint/test/test_catalogue.py +++ b/katpoint/test/test_catalogue.py @@ -16,9 +16,12 @@ """Tests for the catalogue module.""" -import unittest import time +import numpy as np +from numpy.testing import assert_allclose +import pytest + import katpoint @@ -26,15 +29,16 @@ YY = time.localtime().tm_year % 100 -class TestCatalogueConstruction(unittest.TestCase): +class TestCatalogueConstruction: """Test construction of catalogues.""" - def setUp(self): - self.tle_lines = ['# Comment ignored\n', - 'GPS BIIA-21 (PRN 09) \n', - '1 22700U 93042A %02d266.32333151 .00000012 00000-0 10000-3 0 805%1d\n' % - (YY, (YY // 10 + YY - 7 + 4) % 10), - '2 22700 55.4408 61.3790 0191986 78.1802 283.9935 2.00561720104282\n'] + def setup(self): + self.tle_lines = [ + '# Comment ignored\n', + 'GPS BIIA-21 (PRN 09) \n', + '1 22700U 93042A %02d266.32333151 .00000012 00000-0 10000-3 0 805%1d\n' + % (YY, (YY // 10 + YY - 7 + 4) % 10), + '2 22700 55.4408 61.3790 0191986 78.1802 283.9935 2.00561720104282\n'] self.edb_lines = ['# Comment ignored\n', 'HIC 13847,f|S|A4,2:58:16.03,-40:18:17.1,2.906,2000,\n'] self.antenna = katpoint.Antenna('XDM, -25:53:23.05075, 27:41:03.36453, 1406.1086, 15.0') @@ -45,7 +49,7 @@ def test_catalogue_basic(self): repr(cat) str(cat) cat.add('# Comments will be ignored') - with self.assertRaises(ValueError): + with pytest.raises(ValueError): cat.add([1]) def test_catalogue_tab_completion(self): @@ -54,8 +58,7 @@ def test_catalogue_tab_completion(self): cat.add('Earth | Terra Incognita, azel, 0, 0') cat.add('Earth | Sky, azel, 0, 90') # Check that it returns a sorted list - self.assertEqual(cat._ipython_key_completions_(), - ['Earth', 'Nothing', 'Sky', 'Terra Incognita']) + assert cat._ipython_key_completions_() == ['Earth', 'Nothing', 'Sky', 'Terra Incognita'] def test_catalogue_same_name(self): """"Test add() and remove() of targets with the same name.""" @@ -63,65 +66,64 @@ def test_catalogue_same_name(self): targets = ['Sun, special', 'Sun | Sol, special', 'Sun, special hot'] # Add various targets called Sun cat.add(targets[0]) - self.assertEqual(cat['Sun'].description, targets[0]) + assert cat['Sun'].description == targets[0] cat.add(targets[0]) - self.assertEqual(len(cat), 1, 'Did not ignore duplicate target') + assert len(cat) == 1, 'Did not ignore duplicate target' cat.add(targets[1]) - self.assertEqual(cat['Sun'].description, targets[1]) + assert cat['Sun'].description == targets[1] cat.add(targets[2]) - self.assertEqual(cat['Sun'].description, targets[2]) + assert cat['Sun'].description == targets[2] # Check length, iteration, membership - self.assertEqual(len(cat), len(targets)) + assert len(cat) == len(targets) for n, t in enumerate(cat): - self.assertEqual(t.description, targets[n]) - self.assertIn('Sun', cat) - self.assertIn('Sol', cat) + assert t.description == targets[n] + assert 'Sun' in cat + assert 'Sol' in cat for t in targets: - self.assertIn(katpoint.Target(t), cat) + assert katpoint.Target(t) in cat # Remove targets one by one cat.remove('Sun') - self.assertEqual(cat['Sun'].description, targets[1]) + assert cat['Sun'].description == targets[1] cat.remove('Sun') - self.assertEqual(cat['Sun'].description, targets[0]) + assert cat['Sun'].description == targets[0] cat.remove('Sun') - self.assertTrue(len(cat) == len(cat.targets) == len(cat.lookup) == 0, - 'Catalogue not empty') + assert len(cat) == len(cat.targets) == len(cat.lookup) == 0, 'Catalogue not empty' def test_construct_catalogue(self): """Test construction of catalogues.""" cat = katpoint.Catalogue(add_specials=True, add_stars=True, antenna=self.antenna) num_targets_original = len(cat) - self.assertEqual(num_targets_original, len(katpoint.specials) + 1 + len(katpoint.stars.stars), - 'Number of targets incorrect') + assert num_targets_original == len(katpoint.specials) + 1 + len(katpoint.stars.stars) # Add target already in catalogue - no action cat.add(katpoint.Target('Sun, special')) num_targets = len(cat) - self.assertEqual(num_targets, num_targets_original, 'Number of targets incorrect') + assert num_targets == num_targets_original, 'Number of targets incorrect' cat2 = katpoint.Catalogue(add_specials=True, add_stars=True) cat2.add(katpoint.Target('Sun, special')) - self.assertEqual(cat, cat2, 'Catalogues not equal') + assert cat == cat2, 'Catalogues not equal' try: - self.assertEqual(hash(cat), hash(cat2), 'Catalogue hashes not equal') + assert hash(cat) == hash(cat2), 'Catalogue hashes not equal' except TypeError: - self.fail('Catalogue object not hashable') + pytest.fail('Catalogue object not hashable') # Add different targets with the same name cat2.add(katpoint.Target('Sun, special hot')) cat2.add(katpoint.Target('Sun | Sol, special')) - self.assertEqual(len(cat2), num_targets_original + 2, 'Number of targets incorrect') + assert len(cat2) == num_targets_original + 2, 'Number of targets incorrect' cat2.remove('Sol') - self.assertEqual(len(cat2), num_targets_original + 1, 'Number of targets incorrect') - self.assertTrue(cat != cat2, 'Catalogues should not be equal') + assert len(cat2) == num_targets_original + 1, 'Number of targets incorrect' + assert cat != cat2, 'Catalogues should not be equal' test_target = cat.targets[-1] - self.assertEqual(test_target.description, cat[test_target.name].description, 'Lookup failed') - self.assertEqual(cat['Non-existent'], None, 'Lookup of non-existent target failed') + assert test_target.description == cat[test_target.name].description, 'Lookup failed' + assert cat['Non-existent'] == None, 'Lookup of non-existent target failed' cat.add_tle(self.tle_lines, 'tle') cat.add_edb(self.edb_lines, 'edb') - self.assertEqual(len(cat.targets), num_targets + 2, 'Number of targets incorrect') + assert len(cat.targets) == num_targets + 2, 'Number of targets incorrect' cat.remove(cat.targets[-1].name) - self.assertEqual(len(cat.targets), num_targets + 1, 'Number of targets incorrect') + assert len(cat.targets) == num_targets + 1, 'Number of targets incorrect' closest_target, dist = cat.closest_to(test_target) - self.assertEqual(closest_target.description, test_target.description, 'Closest target incorrect') - self.assertAlmostEqual(dist, 0.0, places=5, msg='Target should be on top of itself') + assert closest_target.description == test_target.description, 'Closest target incorrect' + assert_allclose(dist, 0.0, rtol=0.0, atol=0.5e-5, + err_msg='Target should be on top of itself') def test_that_equality_and_hash_ignore_order(self): a = katpoint.Catalogue() @@ -132,66 +134,66 @@ def test_that_equality_and_hash_ignore_order(self): a.add(t2) b.add(t2) b.add(t1) - self.assertEqual(a, b, 'Shuffled catalogues are not equal') - self.assertEqual(hash(a), hash(b), 'Shuffled catalogues have different hashes') + assert a == b, 'Shuffled catalogues are not equal' + assert hash(a) == hash(b), 'Shuffled catalogues have different hashes' def test_skip_empty(self): cat = katpoint.Catalogue(['', '# comment', ' ', '\t\r ']) - self.assertEqual(len(cat), 0) + assert len(cat) == 0 -class TestCatalogueFilterSort(unittest.TestCase): +class TestCatalogueFilterSort: """Test filtering and sorting of catalogues.""" - def setUp(self): + def setup(self): self.flux_target = katpoint.Target('flux, radec, 0.0, 0.0, (1.0 2.0 2.0 0.0 0.0)') self.antenna = katpoint.Antenna('XDM, -25:53:23.05075, 27:41:03.36453, 1406.1086, 15.0') - self.antenna2 = katpoint.Antenna('XDM2, -25:53:23.05075, 27:41:03.36453, 1406.1086, 15.0, 100.0 0.0 0.0') + self.antenna2 = katpoint.Antenna('XDM2, -25:53:23.05075, 27:41:03.36453, ' + '1406.1086, 15.0, 100.0 0.0 0.0') self.timestamp = time.mktime(time.strptime('2009/06/14 12:34:56', '%Y/%m/%d %H:%M:%S')) def test_filter_catalogue(self): """Test filtering of catalogues.""" cat = katpoint.Catalogue(add_specials=True, add_stars=True) cat = cat.filter(tags=['special', '~radec']) - self.assertEqual(len(cat.targets), len(katpoint.specials), 'Number of targets incorrect') + assert len(cat.targets) == len(katpoint.specials), 'Number of targets incorrect' cat.add(self.flux_target) cat2 = cat.filter(flux_limit_Jy=50.0, flux_freq_MHz=1.5) - self.assertEqual(len(cat2.targets), 1, 'Number of targets with sufficient flux should be 1') - self.assertNotEqual(cat, cat2, 'Catalogues should be inequal') + assert len(cat2.targets) == 1, 'Number of targets with sufficient flux should be 1' + assert cat != cat2, 'Catalogues should be inequal' cat3 = cat.filter(az_limit_deg=[0, 180], timestamp=self.timestamp, antenna=self.antenna) - self.assertEqual(len(cat3.targets), 1, 'Number of targets rising should be 1') + assert len(cat3.targets) == 1, 'Number of targets rising should be 1' cat4 = cat.filter(az_limit_deg=[180, 0], timestamp=self.timestamp, antenna=self.antenna) - self.assertEqual(len(cat4.targets), 9, 'Number of targets setting should be 9') + assert len(cat4.targets) == 9, 'Number of targets setting should be 9' cat.add(katpoint.Target('Zenith, azel, 0, 90')) cat5 = cat.filter(el_limit_deg=85, timestamp=self.timestamp, antenna=self.antenna) - self.assertEqual(len(cat5.targets), 1, 'Number of targets close to zenith should be 1') + assert len(cat5.targets) == 1, 'Number of targets close to zenith should be 1' sun = katpoint.Target('Sun, special') cat6 = cat.filter(dist_limit_deg=[0.0, 1.0], proximity_targets=sun, timestamp=self.timestamp, antenna=self.antenna) - self.assertEqual(len(cat6.targets), 1, 'Number of targets close to Sun should be 1') + assert len(cat6.targets) == 1, 'Number of targets close to Sun should be 1' def test_sort_catalogue(self): """Test sorting of catalogues.""" cat = katpoint.Catalogue(add_specials=True, add_stars=True) - self.assertEqual(len(cat.targets), len(katpoint.specials) + 1 + len(katpoint.stars.stars), - 'Number of targets incorrect') + assert len(cat.targets) == len(katpoint.specials) + 1 + len(katpoint.stars.stars) cat1 = cat.sort(key='name') - self.assertEqual(cat1, cat, 'Catalogue equality failed') - self.assertIn(cat1.targets[0].name, {'Acamar', 'Achernar'}, 'Sorting on name failed') + assert cat1 == cat, 'Catalogue equality failed' + assert cat1.targets[0].name in {'Acamar', 'Achernar'}, 'Sorting on name failed' cat2 = cat.sort(key='ra', timestamp=self.timestamp, antenna=self.antenna) - self.assertIn(cat2.targets[0].name, {'Alpheratz', 'Sirrah'}, 'Sorting on ra failed') + assert cat2.targets[0].name in {'Alpheratz', 'Sirrah'}, 'Sorting on ra failed' cat3 = cat.sort(key='dec', timestamp=self.timestamp, antenna=self.antenna) - self.assertIn(cat3.targets[0].name, {'Miaplacidus', 'Agena'}, 'Sorting on dec failed') + assert cat3.targets[0].name in {'Miaplacidus', 'Agena'}, 'Sorting on dec failed' cat4 = cat.sort(key='az', timestamp=self.timestamp, antenna=self.antenna, ascending=False) - self.assertEqual(cat4.targets[0].name, 'Polaris', 'Sorting on az failed') # az: 359:25:07.3 + assert cat4.targets[0].name == 'Polaris', 'Sorting on az failed' # az: 359:25:07.3 cat5 = cat.sort(key='el', timestamp=self.timestamp, antenna=self.antenna) - self.assertEqual(cat5.targets[-1].name, 'Zenith', 'Sorting on el failed') # el: 90:00:00.0 + assert cat5.targets[-1].name == 'Zenith', 'Sorting on el failed' # el: 90:00:00.0 cat.add(self.flux_target) cat6 = cat.sort(key='flux', ascending=False, flux_freq_MHz=1.5) - self.assertTrue('flux' in (cat6.targets[0].name, cat6.targets[-1].name), - 'Flux target should be at start or end of catalogue after sorting') - self.assertTrue((cat6.targets[0].flux_density(1.5) == 100.0) or - (cat6.targets[-1].flux_density(1.5) == 100.0), 'Sorting on flux failed') + assert 'flux' in (cat6.targets[0].name, cat6.targets[-1].name), ( + 'Flux target should be at start or end of catalogue after sorting') + assert ((cat6.targets[0].flux_density(1.5) == 100.0) or + (cat6.targets[-1].flux_density(1.5) == 100.0)), 'Sorting on flux failed' def test_visibility_list(self): """Test output of visibility list.""" diff --git a/katpoint/test/test_conversion.py b/katpoint/test/test_conversion.py index 8f60af6..3371902 100644 --- a/katpoint/test/test_conversion.py +++ b/katpoint/test/test_conversion.py @@ -16,8 +16,6 @@ """Tests for the conversion module.""" -import unittest - import numpy as np import astropy.units as u from astropy.coordinates import Angle @@ -31,10 +29,10 @@ def primary_angle(x): np.testing.assert_almost_equal(primary_angle(x - y), np.zeros(np.shape(x)), decimal=decimal) -class TestGeodetic(unittest.TestCase): +class TestGeodetic: """Closure tests for geodetic coordinate transformations.""" - def setUp(self): + def setup(self): N = 1000 self.lat = 0.999 * np.pi * (np.random.rand(N) - 0.5) self.lon = 2.0 * np.pi * np.random.rand(N) @@ -67,10 +65,10 @@ def test_ecef_to_enu(self): np.testing.assert_almost_equal(new_z, z, decimal=8) -class TestSpherical(unittest.TestCase): +class TestSpherical: """Closure tests for spherical coordinate transformations.""" - def setUp(self): + def setup(self): N = 1000 self.az = Angle(2.0 * np.pi * np.random.rand(N), unit=u.rad) self.el = Angle(0.999 * np.pi * (np.random.rand(N) - 0.5), unit=u.rad) diff --git a/katpoint/test/test_delay.py b/katpoint/test/test_delay.py index 4a7a35e..7178b06 100644 --- a/katpoint/test/test_delay.py +++ b/katpoint/test/test_delay.py @@ -17,17 +17,17 @@ """Tests for the model module.""" import json -import unittest from io import StringIO import numpy as np +import pytest import astropy.units as u from astropy.coordinates import Angle import katpoint -class TestDelayModel(unittest.TestCase): +class TestDelayModel: """Test antenna delay model.""" def test_construct_save_load(self): @@ -36,7 +36,8 @@ def test_construct_save_load(self): m.header['date'] = '2014-01-15' # An empty file should lead to a BadModelFile exception cfg_file = StringIO() - self.assertRaises(katpoint.BadModelFile, m.fromfile, cfg_file) + with pytest.raises(katpoint.BadModelFile): + m.fromfile(cfg_file) m.tofile(cfg_file) cfg_str = cfg_file.getvalue() cfg_file.close() @@ -44,21 +45,21 @@ def test_construct_save_load(self): cfg_file = StringIO(cfg_str) m2 = katpoint.DelayModel() m2.fromfile(cfg_file) - self.assertEqual(m, m2, 'Saving delay model to file and loading it again failed') + assert m == m2, 'Saving delay model to file and loading it again failed' params = m.delay_params m3 = katpoint.DelayModel() m3.fromdelays(params) - self.assertEqual(m, m3, 'Converting delay model to delay parameters and loading it again failed') + assert m == m3, 'Converting delay model to delay parameters and loading it again failed' try: - self.assertEqual(hash(m), hash(m3), 'Delay model hashes not equal') + assert hash(m) == hash(m3), 'Delay model hashes not equal' except TypeError: - self.fail('DelayModel object not hashable') + pytest.fail('DelayModel object not hashable') -class TestDelayCorrection(unittest.TestCase): +class TestDelayCorrection: """Test correlator delay corrections.""" - def setUp(self): + def setup(self): self.target1 = katpoint.construct_azel_target('45:00:00.0', '75:00:00.0') self.target2 = katpoint.Target('Sun, special') self.ant1 = katpoint.Antenna('A1, -31.0, 18.0, 0.0, 12.0, 0.0 0.0 0.0') @@ -73,14 +74,16 @@ def test_construction(self): delays2 = katpoint.DelayCorrection(descr) delays_dict = json.loads(descr) delays2_dict = json.loads(delays2.description) - self.assertEqual(delays2_dict, delays_dict, - 'Objects created through description strings differ') - self.assertRaises(ValueError, katpoint.DelayCorrection, [self.ant1, self.ant2], self.ant3) - self.assertRaises(ValueError, katpoint.DelayCorrection, [self.ant1, self.ant2]) - self.assertRaises(ValueError, katpoint.DelayCorrection, '') + assert delays2_dict == delays_dict, 'Objects created through description strings differ' + with pytest.raises(ValueError): + katpoint.DelayCorrection([self.ant1, self.ant2], self.ant3) + with pytest.raises(ValueError): + katpoint.DelayCorrection([self.ant1, self.ant2]) + with pytest.raises(ValueError): + katpoint.DelayCorrection('') delays3 = katpoint.DelayCorrection([], self.ant1) - self.assertEqual(delays3._params.shape, (0, len(katpoint.DelayModel())), - "Delay correction with no antennas should fail gracefully") + assert delays3._params.shape == (0, len(katpoint.DelayModel())), ( + "Delay correction with no antennas should fail gracefully") def test_correction(self): """Test delay correction.""" @@ -88,12 +91,12 @@ def test_correction(self): delay0, phase0 = self.delays.corrections(self.target1, self.ts) delay1, phase1 = self.delays.corrections(self.target1, self.ts, self.ts + 1.0) # This target is special - direction perpendicular to baseline (and stationary) - self.assertEqual(delay0['A2h'], extra_delay, 'Delay for ant2h should be zero') - self.assertEqual(delay0['A2v'], extra_delay, 'Delay for ant2v should be zero') - self.assertEqual(delay1['A2h'][0], extra_delay, 'Delay for ant2h should be zero') - self.assertEqual(delay1['A2v'][0], extra_delay, 'Delay for ant2v should be zero') - self.assertEqual(delay1['A2h'][1], 0.0, 'Delay rate for ant2h should be zero') - self.assertEqual(delay1['A2v'][1], 0.0, 'Delay rate for ant2v should be zero') + assert delay0['A2h'] == extra_delay, 'Delay for ant2h should be zero' + assert delay0['A2v'] == extra_delay, 'Delay for ant2v should be zero' + assert delay1['A2h'][0] == extra_delay, 'Delay for ant2h should be zero' + assert delay1['A2v'][0] == extra_delay, 'Delay for ant2v should be zero' + assert delay1['A2h'][1] == 0.0, 'Delay rate for ant2h should be zero' + assert delay1['A2v'][1] == 0.0, 'Delay rate for ant2v should be zero' # Compare to target geometric delay calculations delay0, phase0 = self.delays.corrections(self.target2, self.ts) delay1, phase1 = self.delays.corrections(self.target2, self.ts - 0.5, self.ts + 0.5) @@ -110,7 +113,7 @@ def test_delay_cache(self): max_size = katpoint.DelayCorrection.CACHE_SIZE for n in range(max_size + 10): delay0, phase0 = self.delays.corrections(self.target1, self.ts + n) - self.assertEqual(len(self.delays._cache), max_size, 'Delay cache grew past limit') + assert len(self.delays._cache) == max_size, 'Delay cache grew past limit' def test_offset(self): """Test target offset.""" @@ -125,12 +128,12 @@ def test_offset(self): delay0, phase0 = self.delays.corrections(target3, self.ts, offset=offset) delay1, phase1 = self.delays.corrections(target3, self.ts, self.ts + 1.0, offset) # Conspire to return to special target1 - self.assertEqual(delay0['A2h'], extra_delay, 'Delay for ant2h should be zero') - self.assertEqual(delay0['A2v'], extra_delay, 'Delay for ant2v should be zero') - self.assertEqual(delay1['A2h'][0], extra_delay, 'Delay for ant2h should be zero') - self.assertEqual(delay1['A2v'][0], extra_delay, 'Delay for ant2v should be zero') - self.assertEqual(delay1['A2h'][1], 0.0, 'Delay rate for ant2h should be zero') - self.assertEqual(delay1['A2v'][1], 0.0, 'Delay rate for ant2v should be zero') + assert delay0['A2h'] == extra_delay, 'Delay for ant2h should be zero' + assert delay0['A2v'] == extra_delay, 'Delay for ant2v should be zero' + assert delay1['A2h'][0] == extra_delay, 'Delay for ant2h should be zero' + assert delay1['A2v'][0] == extra_delay, 'Delay for ant2v should be zero' + assert delay1['A2h'][1] == 0.0, 'Delay rate for ant2h should be zero' + assert delay1['A2v'][1] == 0.0, 'Delay rate for ant2v should be zero' # Now try (ra, dec) coordinate system radec = self.target1.radec(self.ts, self.ant1) offset = dict(projection_type='ARC', coord_system='radec') diff --git a/katpoint/test/test_flux.py b/katpoint/test/test_flux.py index e9ca768..6c2259d 100644 --- a/katpoint/test/test_flux.py +++ b/katpoint/test/test_flux.py @@ -16,21 +16,21 @@ """Tests for the flux module.""" -import unittest - import numpy as np +import pytest import katpoint -class TestFluxDensityModel(unittest.TestCase): +class TestFluxDensityModel: """Test flux density model calculation.""" - def setUp(self): + def setup(self): self.unit_model = katpoint.FluxDensityModel(100., 200., [0.]) self.unit_model2 = katpoint.FluxDensityModel(100., 200., [0.]) - self.flux_model = katpoint.FluxDensityModel('(1.0 2.0 2.0 0.0 0.0 0.0 0.0 0.0 2.0 0.5 0.25 -0.75)') - with self.assertWarns(FutureWarning): + self.flux_model = katpoint.FluxDensityModel( + '(1.0 2.0 2.0 0.0 0.0 0.0 0.0 0.0 2.0 0.5 0.25 -0.75)') + with pytest.warns(FutureWarning): self.too_many_params = katpoint.FluxDensityModel( '(1.0 2.0 2.0 0.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 0.0 0.0)') self.too_few_params = katpoint.FluxDensityModel('(1.0 2.0 2.0)') @@ -38,31 +38,39 @@ def setUp(self): self.no_flux_target = katpoint.Target('radec, 0.0, 0.0') def test_construct(self): - self.assertRaises(ValueError, katpoint.FluxDensityModel, '1.0 2.0 2.0', 2.0, [2.0]) - self.assertRaises(ValueError, katpoint.FluxDensityModel, '1.0') + with pytest.raises(ValueError): + katpoint.FluxDensityModel('1.0 2.0 2.0', 2.0, [2.0]) + with pytest.raises(ValueError): + katpoint.FluxDensityModel('1.0') def test_description(self): - self.assertEqual(self.flux_model.description, '(1.0 2.0 2.0 0.0 0.0 0.0 0.0 0.0 2.0 0.5 0.25 -0.75)') + assert self.flux_model.description == '(1.0 2.0 2.0 0.0 0.0 0.0 0.0 0.0 2.0 0.5 0.25 -0.75)' # Must truncate default coefficients, including I=1 - self.assertEqual(self.too_many_params.description, '(1.0 2.0 2.0)') + assert self.too_many_params.description == '(1.0 2.0 2.0)' # At least one coefficient is always shown - self.assertEqual(self.unit_model.description, '(100.0 200.0 0.0)') + assert self.unit_model.description == '(100.0 200.0 0.0)' def test_flux_density(self): """Test flux density calculation.""" - self.assertEqual(self.unit_model.flux_density(110.), 1.0, 'Flux calculation wrong') - self.assertEqual(self.flux_model.flux_density(1.5), 200.0, 'Flux calculation wrong') - self.assertEqual(self.too_many_params.flux_density(1.5), 100.0, 'Flux calculation for too many params wrong') - self.assertEqual(self.too_few_params.flux_density(1.5), 100.0, 'Flux calculation for too few params wrong') + assert self.unit_model.flux_density(110.) == 1.0, 'Flux calculation wrong' + assert self.flux_model.flux_density(1.5) == 200.0, 'Flux calculation wrong' + assert self.too_many_params.flux_density(1.5) == 100.0, ( + 'Flux calculation for too many params wrong') + assert self.too_few_params.flux_density(1.5) == 100.0, ( + 'Flux calculation for too few params wrong') np.testing.assert_equal(self.flux_model.flux_density([1.5, 1.5]), - np.array([200.0, 200.0]), 'Flux calculation for multiple frequencies wrong') + np.array([200.0, 200.0]), + 'Flux calculation for multiple frequencies wrong') np.testing.assert_equal(self.flux_model.flux_density([0.5, 2.5]), - np.array([np.nan, np.nan]), 'Flux calculation for out-of-range frequencies wrong') - self.assertRaises(ValueError, self.no_flux_target.flux_density) + np.array([np.nan, np.nan]), + 'Flux calculation for out-of-range frequencies wrong') + with pytest.raises(ValueError): + self.no_flux_target.flux_density() np.testing.assert_equal(self.no_flux_target.flux_density([1.5, 1.5]), - np.array([np.nan, np.nan]), 'Empty flux model leads to wrong empty flux shape') + np.array([np.nan, np.nan]), + 'Empty flux model leads to wrong empty flux shape') self.flux_target.flux_freq_MHz = 1.5 - self.assertEqual(self.flux_target.flux_density(), 200.0, 'Flux calculation for default freq wrong') + assert self.flux_target.flux_density() == 200.0, 'Flux calculation for default freq wrong' print(self.flux_target) def test_flux_density_stokes(self): @@ -73,7 +81,8 @@ def test_flux_density_stokes(self): np.array([[200.0, 50.0, 25.0, -75.0], [200.0, 50.0, 25.0, -75.0], [np.nan, np.nan, np.nan, np.nan]])) - self.assertRaises(ValueError, self.no_flux_target.flux_density_stokes) + with pytest.raises(ValueError): + self.no_flux_target.flux_density_stokes() np.testing.assert_array_equal(self.no_flux_target.flux_density_stokes(1.5), np.array([np.nan, np.nan, np.nan, np.nan]), 'Empty flux model leads to wrong empty flux shape') @@ -87,10 +96,10 @@ def test_flux_density_stokes(self): 'Flux calculation for default freq wrong') def test_compare(self): - self.assertEqual(self.unit_model, self.unit_model2, 'Flux models not equal') + assert self.unit_model == self.unit_model2, 'Flux models not equal' def test_hash(self): try: - self.assertEqual(hash(self.unit_model), hash(self.unit_model2), 'Flux model hashes not equal') + assert hash(self.unit_model) == hash(self.unit_model2), 'Flux model hashes not equal' except TypeError: - self.fail('FluxDensityModel object not hashable') + pytest.fail('FluxDensityModel object not hashable') diff --git a/katpoint/test/test_model.py b/katpoint/test/test_model.py index 958d1cf..c7bb349 100644 --- a/katpoint/test/test_model.py +++ b/katpoint/test/test_model.py @@ -16,13 +16,14 @@ """Tests for the model module.""" -import unittest from io import StringIO +import pytest + import katpoint -class TestModel(unittest.TestCase): +class TestModel: """Test generic model.""" def new_params(self): @@ -44,7 +45,8 @@ def test_construct_save_load(self): print('%r %s %r' % (m, m, m.params['POS_E'])) # An empty file should lead to a BadModelFile exception cfg_file = StringIO() - self.assertRaises(katpoint.BadModelFile, m.fromfile, cfg_file) + with pytest.raises(katpoint.BadModelFile): + m.fromfile(cfg_file) m.tofile(cfg_file) cfg_str = cfg_file.getvalue() cfg_file.close() @@ -52,43 +54,44 @@ def test_construct_save_load(self): cfg_file = StringIO(cfg_str) m2 = katpoint.Model(self.new_params()) m2.fromfile(cfg_file) - self.assertEqual(m, m2, 'Saving model to file and loading it again failed') + assert m == m2, 'Saving model to file and loading it again failed' cfg_file = StringIO(cfg_str) m2.set(cfg_file) - self.assertEqual(m, m2, 'Saving model to file and loading it again failed') + assert m == m2, 'Saving model to file and loading it again failed' # Build model from description string m3 = katpoint.Model(self.new_params()) m3.fromstring(m.description) - self.assertEqual(m, m3, 'Saving model to string and loading it again failed') + assert m == m3, 'Saving model to string and loading it again failed' m3.set(m.description) - self.assertEqual(m, m3, 'Saving model to string and loading it again failed') + assert m == m3, 'Saving model to string and loading it again failed' # Build model from sequence of floats m4 = katpoint.Model(self.new_params()) m4.fromlist(m.values()) - self.assertEqual(m, m4, 'Saving model to list and loading it again failed') + assert m == m4, 'Saving model to list and loading it again failed' m4.set(m.values()) - self.assertEqual(m, m4, 'Saving model to list and loading it again failed') + assert m == m4, 'Saving model to list and loading it again failed' # Empty model cfg_file = StringIO('[header]\n[params]\n') m5 = katpoint.Model(self.new_params()) m5.fromfile(cfg_file) print(m5) - self.assertNotEqual(m, m5, 'Model should not be equal to an empty one') + assert m != m5, 'Model should not be equal to an empty one' m6 = katpoint.Model(self.new_params()) m6.set() - self.assertEqual(m6, m5, 'Setting empty model failed') + assert m6 == m5, 'Setting empty model failed' m7 = katpoint.Model(self.new_params()) m7.set(m) - self.assertEqual(m, m7, 'Construction from model object failed') + assert m == m7, 'Construction from model object failed' class OtherModel(katpoint.Model): pass m8 = OtherModel(self.new_params()) - self.assertRaises(katpoint.BadModelFile, m8.set, m) + with pytest.raises(katpoint.BadModelFile): + m8.set(m) try: - self.assertEqual(hash(m), hash(m4), 'Model hashes not equal') + assert hash(m) == hash(m4), 'Model hashes not equal' except TypeError: - self.fail('Model object not hashable') + pytest.fail('Model object not hashable') def test_dict_interface(self): """Test dict interface of generic model.""" @@ -96,8 +99,8 @@ def test_dict_interface(self): names = [p.name for p in params] values = [p.value for p in params] m = katpoint.Model(params) - self.assertEqual(len(m), 6, 'Unexpected model length') - self.assertEqual(list(m.keys()), names, 'Parameter names do not match') - self.assertEqual(list(m.values()), values, 'Parameter values do not match') + assert len(m) == 6, 'Unexpected model length' + assert list(m.keys()) == names, 'Parameter names do not match' + assert list(m.values()) == values, 'Parameter values do not match' m['NIAO'] = 6789.0 - self.assertEqual(m['NIAO'], 6789.0, 'Parameter setting via dict interface failed') + assert m['NIAO'] == 6789.0, 'Parameter setting via dict interface failed' diff --git a/katpoint/test/test_pointing.py b/katpoint/test/test_pointing.py index 3f37980..df2b12b 100644 --- a/katpoint/test/test_pointing.py +++ b/katpoint/test/test_pointing.py @@ -16,9 +16,8 @@ """Tests for the pointing module.""" -import unittest - import numpy as np +import pytest import katpoint @@ -29,10 +28,10 @@ def primary_angle(x): np.testing.assert_almost_equal(primary_angle(x - y), np.zeros(np.shape(x)), **kwargs) -class TestPointingModel(unittest.TestCase): +class TestPointingModel: """Test pointing model.""" - def setUp(self): + def setup(self): az_range = katpoint.deg2rad(np.arange(-185.0, 275.0, 5.0)) el_range = katpoint.deg2rad(np.arange(0.0, 86.0, 1.0)) mesh_az, mesh_el = np.meshgrid(az_range, el_range) @@ -48,13 +47,15 @@ def test_pointing_model_load_save(self): pm = katpoint.PointingModel(params[:-1]) print('%r %s' % (pm, pm)) pm2 = katpoint.PointingModel(params[:-2]) - self.assertEqual(pm2.values()[-1], 0.0, 'Unspecified pointing model params not zeroed') + assert pm2.values()[-1] == 0.0, 'Unspecified pointing model params not zeroed' pm3 = katpoint.PointingModel(params) - self.assertEqual(pm3.values()[-1], params[-2], 'Superfluous pointing model params not handled correctly') + assert pm3.values()[-1] == params[-2], ( + 'Superfluous pointing model params not handled correctly') pm4 = katpoint.PointingModel(pm.description) - self.assertEqual(pm4.description, pm.description, 'Saving pointing model to string and loading it again failed') - self.assertEqual(pm4, pm, 'Pointing models should be equal') - self.assertNotEqual(pm2, pm, 'Pointing models should be inequal') + assert pm4.description == pm.description, ( + 'Saving pointing model to string and loading it again failed') + assert pm4 == pm, 'Pointing models should be equal' + assert pm2 != pm, 'Pointing models should be inequal' # np.testing.assert_almost_equal(pm4.values(), pm.values(), decimal=6) for (v4, v) in zip(pm4.values(), pm.values()): if type(v4) == float: @@ -62,9 +63,9 @@ def test_pointing_model_load_save(self): else: np.testing.assert_almost_equal(v4.rad, v, decimal=6) try: - self.assertEqual(hash(pm4), hash(pm), 'Pointing model hashes not equal') + assert hash(pm4) == hash(pm), 'Pointing model hashes not equal' except TypeError: - self.fail('PointingModel object not hashable') + pytest.fail('PointingModel object not hashable') def test_pointing_closure(self): """Test closure between pointing correction and its reverse operation.""" @@ -74,8 +75,10 @@ def test_pointing_closure(self): # Test closure on (az, el) grid pointed_az, pointed_el = pm.apply(self.az, self.el) az, el = pm.reverse(pointed_az, pointed_el) - assert_angles_almost_equal(az, self.az, decimal=6, err_msg='Azimuth closure error for params=%s' % (params,)) - assert_angles_almost_equal(el, self.el, decimal=7, err_msg='Elevation closure error for params=%s' % (params,)) + assert_angles_almost_equal(az, self.az, decimal=6, + err_msg='Azimuth closure error for params=%s' % (params,)) + assert_angles_almost_equal(el, self.el, decimal=7, + err_msg='Elevation closure error for params=%s' % (params,)) def test_pointing_fit(self): """Test fitting of pointing model.""" diff --git a/katpoint/test/test_projection.py b/katpoint/test/test_projection.py index fc65da8..9f06a94 100644 --- a/katpoint/test/test_projection.py +++ b/katpoint/test/test_projection.py @@ -16,26 +16,16 @@ """Tests for the projection module.""" -import unittest - import numpy as np +import pytest import katpoint try: from .aips_projection import newpos, dircos - found_aips = True + HAS_AIPS = True except ImportError: - found_aips = False - - -def skip(reason=''): - """Use nose to skip a test.""" - try: - import nose - raise nose.SkipTest(reason) - except ImportError: - pass + HAS_AIPS = False def assert_angles_almost_equal(x, y, decimal): @@ -44,10 +34,10 @@ def primary_angle(x): np.testing.assert_almost_equal(primary_angle(x - y), np.zeros(np.shape(x)), decimal=decimal) -class TestProjectionSIN(unittest.TestCase): +class TestProjectionSIN: """Test orthographic projection.""" - def setUp(self): + def setup(self): self.plane_to_sphere = katpoint.plane_to_sphere['SIN'] self.sphere_to_plane = katpoint.sphere_to_plane['SIN'] N = 100 @@ -71,11 +61,9 @@ def test_random_closure(self): assert_angles_almost_equal(az, aa, decimal=10) assert_angles_almost_equal(el, ee, decimal=10) + @pytest.mark.skipif(not HAS_AIPS, reason="AIPS projection module not found") def test_aips_compatibility(self): """SIN projection: compare with original AIPS routine.""" - if not found_aips: - skip("AIPS projection module not found") - return az, el = self.plane_to_sphere(self.az0, self.el0, self.x, self.y) xx, yy = self.sphere_to_plane(self.az0, self.el0, az, el) az_aips, el_aips = np.zeros(az.shape), np.zeros(el.shape) @@ -85,7 +73,7 @@ def test_aips_compatibility(self): 2, self.az0[n], self.el0[n], self.x[n], self.y[n]) x_aips[n], y_aips[n], ierr = dircos( 2, self.az0[n], self.el0[n], az[n], el[n]) - self.assertEqual(ierr, 0) + assert ierr == 0 assert_angles_almost_equal(az, az_aips, decimal=9) assert_angles_almost_equal(el, el_aips, decimal=9) np.testing.assert_almost_equal(xx, x_aips, decimal=9) @@ -116,8 +104,10 @@ def test_corner_cases(self): xy = np.array(self.sphere_to_plane(0.0, np.pi / 2.0, -np.pi / 2.0, 0.0)) np.testing.assert_almost_equal(xy, [-1.0, 0.0], decimal=12) # Points outside allowed domain on sphere - self.assertRaises(ValueError, self.sphere_to_plane, 0.0, 0.0, np.pi, 0.0) - self.assertRaises(ValueError, self.sphere_to_plane, 0.0, 0.0, 0.0, np.pi) + with pytest.raises(ValueError): + self.sphere_to_plane(0.0, 0.0, np.pi, 0.0) + with pytest.raises(ValueError): + self.sphere_to_plane(0.0, 0.0, 0.0, np.pi) # PLANE TO SPHERE # Origin @@ -142,14 +132,16 @@ def test_corner_cases(self): ae = np.array(self.plane_to_sphere(0.0, -np.pi / 2.0, 0.0, -1.0)) assert_angles_almost_equal(ae, [np.pi, 0.0], decimal=12) # Points outside allowed domain in plane - self.assertRaises(ValueError, self.plane_to_sphere, 0.0, 0.0, 2.0, 0.0) - self.assertRaises(ValueError, self.plane_to_sphere, 0.0, 0.0, 0.0, 2.0) + with pytest.raises(ValueError): + self.plane_to_sphere(0.0, 0.0, 2.0, 0.0) + with pytest.raises(ValueError): + self.plane_to_sphere(0.0, 0.0, 0.0, 2.0) -class TestProjectionTAN(unittest.TestCase): +class TestProjectionTAN: """Test gnomonic projection.""" - def setUp(self): + def setup(self): self.plane_to_sphere = katpoint.plane_to_sphere['TAN'] self.sphere_to_plane = katpoint.sphere_to_plane['TAN'] N = 100 @@ -174,11 +166,9 @@ def test_random_closure(self): assert_angles_almost_equal(az, aa, decimal=8) assert_angles_almost_equal(el, ee, decimal=8) + @pytest.mark.skipif(not HAS_AIPS, reason="AIPS projection module not found") def test_aips_compatibility(self): """TAN projection: compare with original AIPS routine.""" - if not found_aips: - skip("AIPS projection module not found") - return # AIPS TAN only deprojects (x, y) coordinates within unit circle r = self.x * self.x + self.y * self.y az0, el0 = self.az0[r <= 1.0], self.el0[r <= 1.0] @@ -192,7 +182,7 @@ def test_aips_compatibility(self): 3, az0[n], el0[n], x[n], y[n]) x_aips[n], y_aips[n], ierr = dircos( 3, az0[n], el0[n], az[n], el[n]) - self.assertEqual(ierr, 0) + assert ierr == 0 assert_angles_almost_equal(az, az_aips, decimal=10) assert_angles_almost_equal(el, el_aips, decimal=10) np.testing.assert_almost_equal(xx, x_aips, decimal=10) @@ -223,8 +213,10 @@ def test_corner_cases(self): xy = np.array(self.sphere_to_plane(0.0, np.pi / 2.0, -np.pi / 2.0, np.pi / 4.0)) np.testing.assert_almost_equal(xy, [-1.0, 0.0], decimal=12) # Points outside allowed domain on sphere - self.assertRaises(ValueError, self.sphere_to_plane, 0.0, 0.0, np.pi, 0.0) - self.assertRaises(ValueError, self.sphere_to_plane, 0.0, 0.0, 0.0, np.pi) + with pytest.raises(ValueError): + self.sphere_to_plane(0.0, 0.0, np.pi, 0.0) + with pytest.raises(ValueError): + self.sphere_to_plane(0.0, 0.0, 0.0, np.pi) # PLANE TO SPHERE # Origin @@ -250,10 +242,10 @@ def test_corner_cases(self): assert_angles_almost_equal(ae, [np.pi, -np.pi / 4.0], decimal=12) -class TestProjectionARC(unittest.TestCase): +class TestProjectionARC: """Test zenithal equidistant projection.""" - def setUp(self): + def setup(self): self.plane_to_sphere = katpoint.plane_to_sphere['ARC'] self.sphere_to_plane = katpoint.sphere_to_plane['ARC'] N = 100 @@ -278,11 +270,9 @@ def test_random_closure(self): assert_angles_almost_equal(az, aa, decimal=8) assert_angles_almost_equal(el, ee, decimal=8) + @ pytest.mark.skipif(not HAS_AIPS, reason="AIPS projection module not found") def test_aips_compatibility(self): """ARC projection: compare with original AIPS routine.""" - if not found_aips: - skip("AIPS projection module not found") - return az, el = self.plane_to_sphere(self.az0, self.el0, self.x, self.y) xx, yy = self.sphere_to_plane(self.az0, self.el0, az, el) az_aips, el_aips = np.zeros(az.shape), np.zeros(el.shape) @@ -292,7 +282,7 @@ def test_aips_compatibility(self): 4, self.az0[n], self.el0[n], self.x[n], self.y[n]) x_aips[n], y_aips[n], ierr = dircos( 4, self.az0[n], self.el0[n], az[n], el[n]) - self.assertEqual(ierr, 0) + assert ierr == 0 assert_angles_almost_equal(az, az_aips, decimal=8) assert_angles_almost_equal(el, el_aips, decimal=8) np.testing.assert_almost_equal(xx, x_aips, decimal=8) @@ -326,7 +316,8 @@ def test_corner_cases(self): xy = np.array(self.sphere_to_plane(np.pi, 0.0, 0.0, 0.0)) np.testing.assert_almost_equal(np.abs(xy), [np.pi, 0.0], decimal=12) # Points outside allowed domain on sphere - self.assertRaises(ValueError, self.sphere_to_plane, 0.0, 0.0, 0.0, np.pi) + with pytest.raises(ValueError): + self.sphere_to_plane(0.0, 0.0, 0.0, np.pi) # PLANE TO SPHERE # Origin @@ -360,14 +351,16 @@ def test_corner_cases(self): ae = np.array(self.plane_to_sphere(0.0, -np.pi / 2.0, 0.0, -np.pi / 2.0)) assert_angles_almost_equal(ae, [np.pi, 0.0], decimal=12) # Points outside allowed domain in plane - self.assertRaises(ValueError, self.plane_to_sphere, 0.0, 0.0, 4.0, 0.0) - self.assertRaises(ValueError, self.plane_to_sphere, 0.0, 0.0, 0.0, 4.0) + with pytest.raises(ValueError): + self.plane_to_sphere(0.0, 0.0, 4.0, 0.0) + with pytest.raises(ValueError): + self.plane_to_sphere(0.0, 0.0, 0.0, 4.0) -class TestProjectionSTG(unittest.TestCase): +class TestProjectionSTG: """Test stereographic projection.""" - def setUp(self): + def setup(self): self.plane_to_sphere = katpoint.plane_to_sphere['STG'] self.sphere_to_plane = katpoint.sphere_to_plane['STG'] N = 100 @@ -393,11 +386,9 @@ def test_random_closure(self): assert_angles_almost_equal(az, aa, decimal=9) assert_angles_almost_equal(el, ee, decimal=9) + @ pytest.mark.skipif(not HAS_AIPS, reason="AIPS projection module not found") def test_aips_compatibility(self): """STG projection: compare with original AIPS routine.""" - if not found_aips: - skip("AIPS projection module not found") - return az, el = self.plane_to_sphere(self.az0, self.el0, self.x, self.y) xx, yy = self.sphere_to_plane(self.az0, self.el0, az, el) az_aips, el_aips = np.zeros(az.shape), np.zeros(el.shape) @@ -407,7 +398,7 @@ def test_aips_compatibility(self): 6, self.az0[n], self.el0[n], self.x[n], self.y[n]) x_aips[n], y_aips[n], ierr = dircos( 6, self.az0[n], self.el0[n], az[n], el[n]) - self.assertEqual(ierr, 0) + assert ierr == 0 # AIPS NEWPOS STG has poor accuracy on azimuth angle (large closure errors by itself) # assert_angles_almost_equal(az, az_aips, decimal=9) assert_angles_almost_equal(el, el_aips, decimal=9) @@ -439,8 +430,10 @@ def test_corner_cases(self): xy = np.array(self.sphere_to_plane(0.0, np.pi / 2.0, -np.pi / 2.0, 0.0)) np.testing.assert_almost_equal(xy, [-2.0, 0.0], decimal=12) # Points outside allowed domain on sphere - self.assertRaises(ValueError, self.sphere_to_plane, 0.0, 0.0, np.pi, 0.0) - self.assertRaises(ValueError, self.sphere_to_plane, 0.0, 0.0, 0.0, np.pi) + with pytest.raises(ValueError): + self.sphere_to_plane(0.0, 0.0, np.pi, 0.0) + with pytest.raises(ValueError): + self.sphere_to_plane(0.0, 0.0, 0.0, np.pi) # PLANE TO SPHERE # Origin @@ -466,10 +459,10 @@ def test_corner_cases(self): assert_angles_almost_equal(ae, [np.pi, 0.0], decimal=12) -class TestProjectionCAR(unittest.TestCase): +class TestProjectionCAR: """Test plate carree projection.""" - def setUp(self): + def setup(self): self.plane_to_sphere = katpoint.plane_to_sphere['CAR'] self.sphere_to_plane = katpoint.sphere_to_plane['CAR'] N = 100 @@ -508,10 +501,10 @@ def plane_to_sphere_original_ssn(target_az, target_el, ll, mm): return scan_az, scan_el -class TestProjectionSSN(unittest.TestCase): +class TestProjectionSSN: """Test swapped orthographic projection.""" - def setUp(self): + def setup(self): self.plane_to_sphere = katpoint.plane_to_sphere['SSN'] self.sphere_to_plane = katpoint.sphere_to_plane['SSN'] N = 100 @@ -573,8 +566,10 @@ def test_corner_cases(self): xy = np.array(self.sphere_to_plane(0.0, np.pi / 2.0, -np.pi / 2.0, 0.0)) np.testing.assert_almost_equal(xy, [0.0, 1.0], decimal=12) # Points outside allowed domain on sphere - self.assertRaises(ValueError, self.sphere_to_plane, 0.0, 0.0, np.pi, 0.0) - self.assertRaises(ValueError, self.sphere_to_plane, 0.0, 0.0, 0.0, np.pi) + with pytest.raises(ValueError): + self.sphere_to_plane(0.0, 0.0, np.pi, 0.0) + with pytest.raises(ValueError): + self.sphere_to_plane(0.0, 0.0, 0.0, np.pi) # PLANE TO SPHERE # Origin @@ -600,5 +595,7 @@ def test_corner_cases(self): ae = np.array(self.plane_to_sphere(0.0, -1.0, 0.0, np.cos(1.0))) assert_angles_almost_equal(ae, [0.0, -np.pi / 2.0], decimal=12) # Points outside allowed domain in plane - self.assertRaises(ValueError, self.plane_to_sphere, 0.0, 0.0, 2.0, 0.0) - self.assertRaises(ValueError, self.plane_to_sphere, 0.0, 0.0, 0.0, 2.0) + with pytest.raises(ValueError): + self.plane_to_sphere(0.0, 0.0, 2.0, 0.0) + with pytest.raises(ValueError): + self.plane_to_sphere(0.0, 0.0, 0.0, 2.0) diff --git a/katpoint/test/test_refraction.py b/katpoint/test/test_refraction.py index dabae63..60c75ed 100644 --- a/katpoint/test/test_refraction.py +++ b/katpoint/test/test_refraction.py @@ -16,9 +16,8 @@ """Tests for the refraction module.""" -import unittest - import numpy as np +import pytest import katpoint @@ -29,23 +28,24 @@ def primary_angle(x): np.testing.assert_almost_equal(primary_angle(x - y), np.zeros(np.shape(x)), **kwargs) -class TestRefractionCorrection(unittest.TestCase): +class TestRefractionCorrection: """Test refraction correction.""" - def setUp(self): + def setup(self): self.rc = katpoint.RefractionCorrection() self.el = katpoint.deg2rad(np.arange(0.0, 90.1, 0.1)) def test_refraction_basic(self): """Test basic refraction correction properties.""" print(repr(self.rc)) - self.assertRaises(ValueError, katpoint.RefractionCorrection, 'unknown') + with pytest.raises(ValueError): + katpoint.RefractionCorrection('unknown') rc2 = katpoint.RefractionCorrection() - self.assertEqual(self.rc, rc2, 'Refraction models should be equal') + assert self.rc == rc2, 'Refraction models should be equal' try: - self.assertEqual(hash(self.rc), hash(rc2), 'Refraction model hashes should be equal') + assert hash(self.rc) == hash(rc2), 'Refraction model hashes should be equal' except TypeError: - self.fail('RefractionCorrection object not hashable') + pytest.fail('RefractionCorrection object not hashable') def test_refraction_closure(self): """Test closure between refraction correction and its reverse operation.""" diff --git a/katpoint/test/test_stars.py b/katpoint/test/test_stars.py index 06cddda..39f7156 100644 --- a/katpoint/test/test_stars.py +++ b/katpoint/test/test_stars.py @@ -16,31 +16,29 @@ """Tests for the stars module.""" -import unittest import numpy as np from katpoint.stars import readdb -class test_stars(unittest.TestCase): - def test_earth_satellite(self): - - record = 'GPS BIIA-21 (PR,E,9/23.32333151/2019| 6/15.3242/2019| 1/1.32422/2020,' \ - '55.4408,61.379002,0.0191986,78.180199,283.9935,2.0056172,1.2e-07,10428,9.9999997e-05' - - e = readdb(record) - self.assertEqual(e.name, 'GPS BIIA-21 (PR') - self.assertEqual(str(e._epoch), '2019-09-23 07:45:35.842') - self.assertEqual(e._inc, np.deg2rad(55.4408)) - self.assertEqual(e._raan, np.deg2rad(61.379002)) - self.assertEqual(e._e, 0.0191986) - self.assertEqual(e._ap, np.deg2rad(78.180199)) - self.assertEqual(e._M, np.deg2rad(283.9935)) - self.assertEqual(e._n, 2.0056172) - self.assertEqual(e._decay, 1.2e-07) - self.assertEqual(e._orbit, 10428) - self.assertEqual(e._drag, 9.9999997e-05) - - def test_star(self): - record = 'Sadr,f|S|F8,20:22:13.7|2.43,40:15:24|-0.93,2.23,2000,0' - readdb(record) +def test_earth_satellite(): + record = 'GPS BIIA-21 (PR,E,9/23.32333151/2019| 6/15.3242/2019| 1/1.32422/2020,' \ + '55.4408,61.379002,0.0191986,78.180199,283.9935,2.0056172,1.2e-07,10428,9.9999997e-05' + + e = readdb(record) + assert e.name == 'GPS BIIA-21 (PR' + assert str(e._epoch) == '2019-09-23 07:45:35.842' + assert e._inc == np.deg2rad(55.4408) + assert e._raan == np.deg2rad(61.379002) + assert e._e == 0.0191986 + assert e._ap == np.deg2rad(78.180199) + assert e._M == np.deg2rad(283.9935) + assert e._n == 2.0056172 + assert e._decay == 1.2e-07 + assert e._orbit == 10428 + assert e._drag == 9.9999997e-05 + + +def test_star(): + record = 'Sadr,f|S|F8,20:22:13.7|2.43,40:15:24|-0.93,2.23,2000,0' + readdb(record) diff --git a/katpoint/test/test_target.py b/katpoint/test/test_target.py index e41f8da..8b75e83 100644 --- a/katpoint/test/test_target.py +++ b/katpoint/test/test_target.py @@ -16,11 +16,11 @@ """Tests for the target module.""" -import unittest import time import pickle import numpy as np +import pytest import astropy.units as u from astropy.coordinates import Angle @@ -30,10 +30,10 @@ YY = time.localtime().tm_year % 100 -class TestTargetConstruction(unittest.TestCase): +class TestTargetConstruction: """Test construction of targets from strings and vice versa.""" - def setUp(self): + def setup(self): self.valid_targets = ['azel, -30.0, 90.0', ', azel, 180, -45:00:00.0', 'Zenith, azel, 0, 90', @@ -92,46 +92,50 @@ def test_construct_target(self): valid_strings = [t.description for t in valid_targets] for descr in valid_strings: t = katpoint.Target(descr) - self.assertEqual(descr, t.description, "Target description ('%s') differs from original string ('%s')" % - (t.description, descr)) + assert descr == t.description, ( + "Target description ('%s') differs from original string ('%s')" + % (t.description, descr)) print('%r %s' % (t, t)) for descr in self.invalid_targets: - self.assertRaises(ValueError, katpoint.Target, descr) + with pytest.raises(ValueError): + katpoint.Target(descr) azel1 = katpoint.Target(self.azel_target) azel2 = katpoint.construct_azel_target('10:00:00.0', '-10:00:00.0') - self.assertEqual(azel1, azel2, 'Special azel constructor failed') + assert azel1 == azel2, 'Special azel constructor failed' radec1 = katpoint.Target(self.radec_target) radec2 = katpoint.construct_radec_target('20.0', '-20.0') - self.assertEqual(radec1, radec2, 'Special radec constructor (decimal) failed') + assert radec1 == radec2, 'Special radec constructor (decimal) failed' radec3 = katpoint.Target(self.radec_target_rahours) radec4 = katpoint.construct_radec_target('20:00:00.0', '-20:00:00.0') - self.assertEqual(radec3, radec4, 'Special radec constructor (sexagesimal) failed') + assert radec3 == radec4, 'Special radec constructor (sexagesimal) failed' radec5 = katpoint.construct_radec_target('20:00:00.0', '-00:30:00.0') radec6 = katpoint.construct_radec_target('300.0', '-0.5') - self.assertEqual(radec5, radec6, 'Special radec constructor (decimal <-> sexagesimal) failed') + assert radec5 == radec6, ( + 'Special radec constructor (decimal <-> sexagesimal) failed') # Check that description string updates when object is updated t1 = katpoint.Target('piet, azel, 20, 30') t2 = katpoint.Target('piet | bollie, azel, 20, 30') - self.assertNotEqual(t1, t2, 'Targets should not be equal') + assert t1 != t2, 'Targets should not be equal' t1.aliases += ['bollie'] - self.assertEqual(t1.description, t2.description, 'Target description string not updated') - self.assertEqual(t1, t2.description, 'Equality with description string failed') - self.assertEqual(t1, t2, 'Equality with target failed') - self.assertEqual(t1, katpoint.Target(t2), 'Construction with target object failed') - self.assertEqual(t1, pickle.loads(pickle.dumps(t1)), 'Pickling failed') + assert t1.description == t2.description, ( + 'Target description string not updated') + assert t1 == t2.description, 'Equality with description string failed' + assert t1 == t2, 'Equality with target failed' + assert t1 == katpoint.Target(t2), 'Construction with target object failed' + assert t1 == pickle.loads(pickle.dumps(t1)), 'Pickling failed' try: - self.assertEqual(hash(t1), hash(t2), 'Target hashes not equal') + assert hash(t1) == hash(t2), 'Target hashes not equal' except TypeError: - self.fail('Target object not hashable') + pytest.fail('Target object not hashable') def test_constructed_coords(self): """Test whether calculated coordinates match those with which it is constructed.""" # azel = katpoint.Target(self.azel_target) # calc_azel = azel.azel() - # calc_az = calc_azel.az; - # calc_el = calc_azel.alt; - # self.assertEqual(calc_az.deg, 10.0, 'Calculated az does not match specified value in azel target') - # self.assertEqual(calc_el.deg, -10.0, 'Calculated el does not match specified value in azel target') + # calc_az = calc_azel.az + # calc_el = calc_azel.alt + # assert calc_az.deg == 10.0, 'Calculated az does not match specified value in azel target' + # assert calc_el.deg == -10.0, 'Calculated el does not match specified value in azel target' radec = katpoint.Target(self.radec_target) calc_radec = radec.radec() calc_ra = calc_radec.ra @@ -159,13 +163,14 @@ def test_add_tags(self): tag_target.add_tags(None) tag_target.add_tags('pulsar') tag_target.add_tags(['SNR', 'GPS']) - self.assertEqual(tag_target.tags, ['azel', 'J2000', 'GPS', 'pulsar', 'SNR'], 'Added tags not correct') + assert tag_target.tags == ['azel', 'J2000', 'GPS', 'pulsar', 'SNR'], ( + 'Added tags not correct') -class TestTargetCalculations(unittest.TestCase): +class TestTargetCalculations: """Test various calculations involving antennas and timestamps.""" - def setUp(self): + def setup(self): self.target = katpoint.construct_azel_target('45:00:00.0', '75:00:00.0') self.ant1 = katpoint.Antenna('A1, -31.0, 18.0, 0.0, 12.0, 0.0 0.0 0.0') self.ant2 = katpoint.Antenna('A2, -31.0, 18.0, 0.0, 12.0, 10.0 -10.0 0.0') diff --git a/katpoint/test/test_timestamp.py b/katpoint/test/test_timestamp.py index 6a6d02f..1691c2e 100644 --- a/katpoint/test/test_timestamp.py +++ b/katpoint/test/test_timestamp.py @@ -16,17 +16,16 @@ """Tests for the timestamp module.""" -import unittest - +import pytest from astropy.time import Time import katpoint -class TestTimestamp(unittest.TestCase): +class TestTimestamp: """Test timestamp creation and conversion.""" - def setUp(self): + def setup(self): self.valid_timestamps = [(1248186982.3980861, '2009-07-21 14:36:22.398'), (Time('2009-07-21 02:52:12.34'), '2009-07-21 02:52:12.340'), (0, '1970-01-01 00:00:00'), @@ -55,38 +54,42 @@ def test_construct_timestamp(self): """Test construction of timestamps.""" for v, s in self.valid_timestamps: t = katpoint.Timestamp(v) - self.assertEqual(str(t), s, "Timestamp string ('%s') differs from expected one ('%s')" % (str(t), s)) + assert str(t) == s, ( + "Timestamp string ('%s') differs from expected one ('%s')" + % (str(t), s)) for v in self.invalid_timestamps: - self.assertRaises(ValueError, katpoint.Timestamp, v) + with pytest.raises(ValueError): + katpoint.Timestamp(v) # for v in self.overflow_timestamps: -# self.assertRaises(OverflowError, katpoint.Timestamp, v) +# with pytest.raises(OverflowError): +# katpoint.Timestamp(v) def test_numerical_timestamp(self): """Test numerical properties of timestamps.""" t = katpoint.Timestamp(self.valid_timestamps[0][0]) - self.assertEqual(t, t + 0.0) - self.assertNotEqual(t, t + 1.0) - self.assertTrue(t > t - 1.0) - self.assertTrue(t < t + 1.0) - self.assertEqual(t, eval('katpoint.' + repr(t))) - self.assertEqual(float(t), self.valid_timestamps[0][0]) + assert t == t + 0.0 + assert t != t + 1.0 + assert t > t - 1.0 + assert t < t + 1.0 + assert t == eval('katpoint.' + repr(t)) + assert float(t) == self.valid_timestamps[0][0] t = katpoint.Timestamp(self.valid_timestamps[1][0]) # self.assertAlmostEqual(t.to_ephem_date(), self.valid_timestamps[1][0], places=9) - self.assertEqual(t.to_ephem_date().value, self.valid_timestamps[1][0]) + assert t.to_ephem_date().value == self.valid_timestamps[1][0] try: - self.assertEqual(hash(t), hash(t + 0.0), 'Timestamp hashes not equal') + assert hash(t) == hash(t + 0.0), 'Timestamp hashes not equal' except TypeError: - self.fail('Timestamp object not hashable') + pytest.fail('Timestamp object not hashable') def test_operators(self): """Test operators defined for timestamps.""" T = katpoint.Timestamp(self.valid_timestamps[0][0]) S = T.secs # Logical operators, float treated as absolute time - self.assertTrue(T == S) - self.assertTrue(T < S+1) - self.assertTrue(T > S-1) + assert T == S + assert T < S + 1 + assert T > S - 1 # Arithmetic operators, float treated as interval - self.assertTrue(isinstance(T - S, katpoint.Timestamp)) - self.assertTrue(isinstance(S - T, float)) - self.assertTrue(isinstance(T - T, float)) + assert isinstance(T - S, katpoint.Timestamp) + assert isinstance(S - T, float) + assert isinstance(T - T, float) diff --git a/setup.py b/setup.py index 876db91..4a690bb 100755 --- a/setup.py +++ b/setup.py @@ -64,7 +64,6 @@ "sgp4", ], tests_require=[ - "nose", - "coverage", - "nosexcover", + "pytest", + "pytest-cov", ]) diff --git a/test-requirements.txt b/test-requirements.txt index 33f4945..9955dec 100644 --- a/test-requirements.txt +++ b/test-requirements.txt @@ -1,2 +1,2 @@ -coverage -nose +pytest +pytest-cov From 3f52f277684472cf37eee7bf4fbd825afb473cb8 Mon Sep 17 00:00:00 2001 From: Ludwig Schwardt Date: Fri, 3 Jul 2020 15:13:38 +0200 Subject: [PATCH 009/122] Tighten star-based catalogue checks Since the latest PyEphem stars catalogue containing 115 stars has now been internalised in katpoint, adjust the relevant tests so that they no longer cater for the older 94-star catalogue as well. --- katpoint/test/test_catalogue.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/katpoint/test/test_catalogue.py b/katpoint/test/test_catalogue.py index e644ce5..01f5346 100644 --- a/katpoint/test/test_catalogue.py +++ b/katpoint/test/test_catalogue.py @@ -18,7 +18,6 @@ import time -import numpy as np from numpy.testing import assert_allclose import pytest @@ -114,7 +113,7 @@ def test_construct_catalogue(self): assert cat != cat2, 'Catalogues should not be equal' test_target = cat.targets[-1] assert test_target.description == cat[test_target.name].description, 'Lookup failed' - assert cat['Non-existent'] == None, 'Lookup of non-existent target failed' + assert cat['Non-existent'] is None, 'Lookup of non-existent target failed' cat.add_tle(self.tle_lines, 'tle') cat.add_edb(self.edb_lines, 'edb') assert len(cat.targets) == num_targets + 2, 'Number of targets incorrect' @@ -179,11 +178,11 @@ def test_sort_catalogue(self): assert len(cat.targets) == len(katpoint.specials) + 1 + len(katpoint.stars.stars) cat1 = cat.sort(key='name') assert cat1 == cat, 'Catalogue equality failed' - assert cat1.targets[0].name in {'Acamar', 'Achernar'}, 'Sorting on name failed' + assert cat1.targets[0].name == 'Acamar', 'Sorting on name failed' cat2 = cat.sort(key='ra', timestamp=self.timestamp, antenna=self.antenna) - assert cat2.targets[0].name in {'Alpheratz', 'Sirrah'}, 'Sorting on ra failed' + assert cat2.targets[0].name == 'Alpheratz', 'Sorting on ra failed' cat3 = cat.sort(key='dec', timestamp=self.timestamp, antenna=self.antenna) - assert cat3.targets[0].name in {'Miaplacidus', 'Agena'}, 'Sorting on dec failed' + assert cat3.targets[0].name == 'Miaplacidus', 'Sorting on dec failed' cat4 = cat.sort(key='az', timestamp=self.timestamp, antenna=self.antenna, ascending=False) assert cat4.targets[0].name == 'Polaris', 'Sorting on az failed' # az: 359:25:07.3 cat5 = cat.sort(key='el', timestamp=self.timestamp, antenna=self.antenna) From a3f3254129f31a9cf6d3aca06bbee5fd0655d53e Mon Sep 17 00:00:00 2001 From: Ludwig Schwardt Date: Mon, 6 Jul 2020 12:14:28 +0200 Subject: [PATCH 010/122] Enable GitLab CI This starts off with a .gitlab-ci.yml file copied from ska-telescope/templates/ska-python-skeleton, commit 236c9c45. Also incorporate some .gitignore lines from the version in the skeleton repository. --- .gitignore | 20 ++++++++-- .gitlab-ci.yml | 101 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 118 insertions(+), 3 deletions(-) create mode 100644 .gitlab-ci.yml diff --git a/.gitignore b/.gitignore index 9fb4917..0fd4604 100644 --- a/.gitignore +++ b/.gitignore @@ -4,13 +4,13 @@ *.so *.so.dSYM -# Packages +# Packages / virtual envs *.egg *.egg-info dist build eggs -.eggs +*.eggs parts bin var @@ -19,7 +19,10 @@ develop-eggs .installed.cfg lib lib64 +.cache __pycache__ +.ipynb_checkpoints +Pipfile.lock # Installer logs pip-log.txt @@ -28,10 +31,21 @@ pip-log.txt .coverage .tox nosetests.xml +htmlcov +coverage.xml +.pytest_cache +*,cover -# Developer tools +# Documentation +docs/_build +docs/apidocs + +# Developer tools / IDEs *~ .ropeproject +.idea +.eclipse +.vscode # Downloaded AIPS source files katpoint/test/aips_projection/*.F diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml new file mode 100644 index 0000000..a39700f --- /dev/null +++ b/.gitlab-ci.yml @@ -0,0 +1,101 @@ +# GitLab CI in conjunction with GitLab Runner can use Docker Engine to test and build any application. +# Docker, when used with GitLab CI, runs each job in a separate and isolated container using the predefined image that is set up in .gitlab-ci.yml. +# In this case we use the latest python docker image to build and test this project. +image: nexus.engageska-portugal.pt/ska-docker/ska-python-buildenv:latest + +# cache is used to specify a list of files and directories which should be cached between jobs. You can only use paths that are within the project workspace. +# If cache is defined outside the scope of jobs, it means it is set globally and all jobs will use that definition +cache: + paths: +# before_script is used to define the command that should be run before all jobs, including deploy jobs, but after the restoration of artifacts. +# This can be an array or a multi-line string. +before_script: + - python3 -m pip install -r docker-requirements.txt + + +stages: + - test + - linting + - deploy + +# The YAML file defines a set of jobs with constraints stating when they should be run. +# You can specify an unlimited number of jobs which are defined as top-level elements with an arbitrary name and always +# have to contain at least the script clause. +# In this case we have only the test job which produces a coverage report and the unittest output (see setup.cfg), and +# the coverage xml report is moved to the reports directory while the html output is persisted for use by the pages +# job. TODO: possibly a candidate for refactor / renaming later on. +test: + stage: test +# tags: +# - docker-executor + script: + # - pipenv run python setup.py test + - python3 setup.py test + - mv coverage.xml ./build/reports/code-coverage.xml + artifacts: + paths: + - ./build + - htmlcov + +list_dependencies: + stage: test + script: + # - pipenv graph >> pipenv_deps.txt + - pipdeptree --json >> pip_deps.json + - pipdeptree >> pip_deps.txt + - dpkg -l >> system_deps.txt + - awk 'FNR>5 {print $2 ", " $3}' system_deps.txt >> system_deps.csv + - mkdir .public + - cp pip_deps.txt .public/ + - cp pip_deps.json .public/ + - cp system_deps.txt .public/ + - cp system_deps.csv .public/ + - mv .public public + artifacts: + paths: + - public + +linting: + image: nexus.engageska-portugal.pt/ska-docker/ska-python-buildenv:latest + tags: + - docker-executor + stage: linting + script: + - make lint + when: always + artifacts: + paths: + - ./build + +pages: + stage: deploy + tags: + - docker-executor + dependencies: + - test + script: + - ls -la + - mkdir .public + - cp -r htmlcov/* .public + - rm -rf htmlcov + - mv .public public + artifacts: + paths: + - public + expire_in: 30 days + +create ci metrics: + stage: .post + image: nexus.engageska-portugal.pt/ska-docker/ska-python-buildenv:latest + when: always + tags: + - docker-executor + script: + # Gitlab CI badges creation: START + - apt-get -y update + - apt-get install -y curl --no-install-recommends + - curl -s https://gitlab.com/ska-telescope/ci-metrics-utilities/raw/master/scripts/ci-badges-func.sh | sh + # Gitlab CI badges creation: END + artifacts: + paths: + - ./build \ No newline at end of file From 9872f17996e0497b0eb0cd004ffad618a7f87b8b Mon Sep 17 00:00:00 2001 From: Ludwig Schwardt Date: Mon, 6 Jul 2020 13:30:11 +0200 Subject: [PATCH 011/122] Add SKA GitLab CI requirements file This is a copy of the one in ska-telescope/templates/ska-python-skeleton, commit 236c9c45. --- docker-requirements.txt | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 docker-requirements.txt diff --git a/docker-requirements.txt b/docker-requirements.txt new file mode 100644 index 0000000..040cecb --- /dev/null +++ b/docker-requirements.txt @@ -0,0 +1,16 @@ +docutils +markupsafe +pygments +pylint +pytest +pytest-cov +pytest-pylint +python-dotenv>=0.5.1 +setuptools +sphinx +sphinx_rtd_theme +sphinx-autobuild +sphinx-rtd-theme +sphinxcontrib-websupport +pipdeptree +pylint_junit \ No newline at end of file From 71471d0847a8ee85fdcbcc76d6acdad10b330608 Mon Sep 17 00:00:00 2001 From: Ludwig Schwardt Date: Mon, 6 Jul 2020 13:39:04 +0200 Subject: [PATCH 012/122] Add katpoint-specific packages to test environment And sort requirements.txt alphabetically. C'mon :-) --- docker-requirements.txt | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/docker-requirements.txt b/docker-requirements.txt index 040cecb..bf65756 100644 --- a/docker-requirements.txt +++ b/docker-requirements.txt @@ -1,16 +1,21 @@ +astropy docutils markupsafe +numpy +pipdeptree pygments pylint +pylint_junit +pyorbital pytest pytest-cov pytest-pylint python-dotenv>=0.5.1 +requests # from pyorbital +scipy # from pyorbital setuptools +sgp4 sphinx -sphinx_rtd_theme sphinx-autobuild sphinx-rtd-theme sphinxcontrib-websupport -pipdeptree -pylint_junit \ No newline at end of file From a2b9b4920f242523fec525b3722b8d51789db5db Mon Sep 17 00:00:00 2001 From: Ludwig Schwardt Date: Mon, 6 Jul 2020 15:11:22 +0200 Subject: [PATCH 013/122] Remove nose testsuite and Python 2 classifiers The nose.collector was run by `python setup.py test`. Strip out older versions of Python in classifier list. --- setup.py | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/setup.py b/setup.py index 4a690bb..c492a29 100755 --- a/setup.py +++ b/setup.py @@ -40,14 +40,10 @@ "License :: OSI Approved :: BSD License", "Operating System :: OS Independent", "Programming Language :: Python", - "Programming Language :: Python :: 2", - "Programming Language :: Python :: 2.7", - "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.3", - "Programming Language :: Python :: 3.4", "Programming Language :: Python :: 3.5", "Programming Language :: Python :: 3.6", "Programming Language :: Python :: 3.7", + "Programming Language :: Python :: 3.8", "Topic :: Software Development :: Libraries :: Python Modules", "Topic :: Scientific/Engineering :: Astronomy"], platforms=["OS Independent"], @@ -56,7 +52,6 @@ python_requires='>=3.5, <4', setup_requires=['katversion'], use_katversion=True, - test_suite="nose.collector", install_requires=[ "astropy", "numpy", From 03709edb3365c3bc4e705d5ee7d60fe1f6906c5c Mon Sep 17 00:00:00 2001 From: Ludwig Schwardt Date: Mon, 6 Jul 2020 15:13:08 +0200 Subject: [PATCH 014/122] Rework GitLab CI testing and linting Give requirements.txt file a custom name since there are already too many. Use the canonical package name for pylint-junit. Based on Rascil's CI setup, call pytest directly (setup.test currently fails with missing module attributes, probably because I hide them in __init__.py). Use the incantations found in Rascil's yml as well as the skeleton's setup.cfg (but don't pollute that file, lest one always want all the test output formats...). Fix linting to call pylint directly, as done by Rascil (but use the already installed pylint-junit and not the other pylint2junit ???). --- .gitlab-ci.yml | 14 ++++++++------ ...-requirements.txt => gitlab-ci-requirements.txt | 2 +- 2 files changed, 9 insertions(+), 7 deletions(-) rename docker-requirements.txt => gitlab-ci-requirements.txt (95%) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index a39700f..837134a 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -10,15 +10,15 @@ cache: # before_script is used to define the command that should be run before all jobs, including deploy jobs, but after the restoration of artifacts. # This can be an array or a multi-line string. before_script: - - python3 -m pip install -r docker-requirements.txt - + - python3 -m pip install -r gitlab-ci-requirements.txt + stages: - test - linting - deploy -# The YAML file defines a set of jobs with constraints stating when they should be run. +# The YAML file defines a set of jobs with constraints stating when they should be run. # You can specify an unlimited number of jobs which are defined as top-level elements with an arbitrary name and always # have to contain at least the script clause. # In this case we have only the test job which produces a coverage report and the unittest output (see setup.cfg), and @@ -30,7 +30,8 @@ test: # - docker-executor script: # - pipenv run python setup.py test - - python3 setup.py test + # - python3 setup.py test + - pytest --cov katpoint --json-report --json-report-file=htmlcov/report.json --cov-report term --cov-report html --cov-report xml --junitxml=./build/reports/unit-tests.xml - mv coverage.xml ./build/reports/code-coverage.xml artifacts: paths: @@ -61,7 +62,8 @@ linting: - docker-executor stage: linting script: - - make lint + - pylint --exit-zero --output-format=pylint_junit.JUnitReporter katpoint > linting.xml + - pylint --exit-zero --output-format=parseable katpoint when: always artifacts: paths: @@ -98,4 +100,4 @@ create ci metrics: # Gitlab CI badges creation: END artifacts: paths: - - ./build \ No newline at end of file + - ./build diff --git a/docker-requirements.txt b/gitlab-ci-requirements.txt similarity index 95% rename from docker-requirements.txt rename to gitlab-ci-requirements.txt index bf65756..7a8109a 100644 --- a/docker-requirements.txt +++ b/gitlab-ci-requirements.txt @@ -5,7 +5,7 @@ numpy pipdeptree pygments pylint -pylint_junit +pylint-junit pyorbital pytest pytest-cov From 8f291ab2a01f1e5d888a74634aa0577632c34278 Mon Sep 17 00:00:00 2001 From: Ludwig Schwardt Date: Mon, 6 Jul 2020 15:38:18 +0200 Subject: [PATCH 015/122] Strip runner tags and consolidate CI metrics I could not find any "docker-executor"-tagged CI runner, so chuck it. Move all XML files to ./build/reports in the final deployment step, again copying RASCIL. At least this ensures that the path exists. --- .gitlab-ci.yml | 20 ++++++-------------- 1 file changed, 6 insertions(+), 14 deletions(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 837134a..cd41f2a 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -26,16 +26,12 @@ stages: # job. TODO: possibly a candidate for refactor / renaming later on. test: stage: test -# tags: -# - docker-executor script: # - pipenv run python setup.py test # - python3 setup.py test - - pytest --cov katpoint --json-report --json-report-file=htmlcov/report.json --cov-report term --cov-report html --cov-report xml --junitxml=./build/reports/unit-tests.xml - - mv coverage.xml ./build/reports/code-coverage.xml + - pytest --cov katpoint --json-report --json-report-file=htmlcov/report.json --cov-report term --cov-report html --cov-report xml --junitxml=unit-tests.xml artifacts: paths: - - ./build - htmlcov list_dependencies: @@ -58,21 +54,14 @@ list_dependencies: linting: image: nexus.engageska-portugal.pt/ska-docker/ska-python-buildenv:latest - tags: - - docker-executor stage: linting script: - pylint --exit-zero --output-format=pylint_junit.JUnitReporter katpoint > linting.xml - pylint --exit-zero --output-format=parseable katpoint when: always - artifacts: - paths: - - ./build pages: stage: deploy - tags: - - docker-executor dependencies: - test script: @@ -90,8 +79,11 @@ create ci metrics: stage: .post image: nexus.engageska-portugal.pt/ska-docker/ska-python-buildenv:latest when: always - tags: - - docker-executor + before_script: + - mkdir -p build/reports + - mv coverage.xml ./build/reports/code-coverage.xml + - mv linting.xml ./build/reports/linting.xml + - mv unit-tests.xml ./build/reports/unit-tests.xml script: # Gitlab CI badges creation: START - apt-get -y update From 7d5629d00d91a8281af2d928cc9d885c7f3d3065 Mon Sep 17 00:00:00 2001 From: Ludwig Schwardt Date: Mon, 6 Jul 2020 16:03:58 +0200 Subject: [PATCH 016/122] Revert build/reports creation The CI metrics step could not find the artefacts, so go back a bit. --- .gitlab-ci.yml | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index cd41f2a..fc238df 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -29,9 +29,11 @@ test: script: # - pipenv run python setup.py test # - python3 setup.py test - - pytest --cov katpoint --json-report --json-report-file=htmlcov/report.json --cov-report term --cov-report html --cov-report xml --junitxml=unit-tests.xml + - pytest --cov katpoint --json-report --json-report-file=htmlcov/report.json --cov-report term --cov-report html --cov-report xml --junitxml=./build/reports/unit-tests.xml + - mv coverage.xml ./build/reports/code-coverage.xml artifacts: paths: + - ./build - htmlcov list_dependencies: @@ -53,12 +55,15 @@ list_dependencies: - public linting: - image: nexus.engageska-portugal.pt/ska-docker/ska-python-buildenv:latest stage: linting script: - pylint --exit-zero --output-format=pylint_junit.JUnitReporter katpoint > linting.xml + - mv linting.xml ./build/reports/linting.xml - pylint --exit-zero --output-format=parseable katpoint when: always + artifacts: + paths: + - ./build pages: stage: deploy @@ -77,13 +82,7 @@ pages: create ci metrics: stage: .post - image: nexus.engageska-portugal.pt/ska-docker/ska-python-buildenv:latest when: always - before_script: - - mkdir -p build/reports - - mv coverage.xml ./build/reports/code-coverage.xml - - mv linting.xml ./build/reports/linting.xml - - mv unit-tests.xml ./build/reports/unit-tests.xml script: # Gitlab CI badges creation: START - apt-get -y update From 14ce1cd2d5468a77696626d0505bc2bd88a093ee Mon Sep 17 00:00:00 2001 From: Ludwig Schwardt Date: Tue, 7 Jul 2020 00:01:44 +0200 Subject: [PATCH 017/122] Streamline the CI process Ditch some of the cargo (but we are still descending...). --- .gitignore | 18 ++---------------- .gitlab-ci.yml | 27 ++------------------------- gitlab-ci-requirements.txt | 2 -- 3 files changed, 4 insertions(+), 43 deletions(-) diff --git a/.gitignore b/.gitignore index 0fd4604..c2f6168 100644 --- a/.gitignore +++ b/.gitignore @@ -5,24 +5,12 @@ *.so.dSYM # Packages / virtual envs -*.egg -*.egg-info -dist build -eggs +dist *.eggs -parts -bin -var -sdist -develop-eggs -.installed.cfg -lib -lib64 +*.egg-info .cache __pycache__ -.ipynb_checkpoints -Pipfile.lock # Installer logs pip-log.txt @@ -30,11 +18,9 @@ pip-log.txt # Unit test / coverage reports .coverage .tox -nosetests.xml htmlcov coverage.xml .pytest_cache -*,cover # Documentation docs/_build diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index fc238df..2eabce8 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -1,36 +1,17 @@ -# GitLab CI in conjunction with GitLab Runner can use Docker Engine to test and build any application. -# Docker, when used with GitLab CI, runs each job in a separate and isolated container using the predefined image that is set up in .gitlab-ci.yml. -# In this case we use the latest python docker image to build and test this project. image: nexus.engageska-portugal.pt/ska-docker/ska-python-buildenv:latest -# cache is used to specify a list of files and directories which should be cached between jobs. You can only use paths that are within the project workspace. -# If cache is defined outside the scope of jobs, it means it is set globally and all jobs will use that definition -cache: - paths: -# before_script is used to define the command that should be run before all jobs, including deploy jobs, but after the restoration of artifacts. -# This can be an array or a multi-line string. before_script: - python3 -m pip install -r gitlab-ci-requirements.txt - stages: - test - linting - deploy -# The YAML file defines a set of jobs with constraints stating when they should be run. -# You can specify an unlimited number of jobs which are defined as top-level elements with an arbitrary name and always -# have to contain at least the script clause. -# In this case we have only the test job which produces a coverage report and the unittest output (see setup.cfg), and -# the coverage xml report is moved to the reports directory while the html output is persisted for use by the pages -# job. TODO: possibly a candidate for refactor / renaming later on. test: stage: test script: - # - pipenv run python setup.py test - # - python3 setup.py test - - pytest --cov katpoint --json-report --json-report-file=htmlcov/report.json --cov-report term --cov-report html --cov-report xml --junitxml=./build/reports/unit-tests.xml - - mv coverage.xml ./build/reports/code-coverage.xml + - pytest --cov katpoint --cov-branch --cov-report term --cov-report html --cov-report xml:./build/reports/code-coverage.xml --junitxml=./build/reports/unit-tests.xml artifacts: paths: - ./build @@ -39,7 +20,6 @@ test: list_dependencies: stage: test script: - # - pipenv graph >> pipenv_deps.txt - pipdeptree --json >> pip_deps.json - pipdeptree >> pip_deps.txt - dpkg -l >> system_deps.txt @@ -57,8 +37,7 @@ list_dependencies: linting: stage: linting script: - - pylint --exit-zero --output-format=pylint_junit.JUnitReporter katpoint > linting.xml - - mv linting.xml ./build/reports/linting.xml + - pylint --exit-zero --output-format=pylint_junit.JUnitReporter katpoint > ./build/reports/linting.xml - pylint --exit-zero --output-format=parseable katpoint when: always artifacts: @@ -85,8 +64,6 @@ create ci metrics: when: always script: # Gitlab CI badges creation: START - - apt-get -y update - - apt-get install -y curl --no-install-recommends - curl -s https://gitlab.com/ska-telescope/ci-metrics-utilities/raw/master/scripts/ci-badges-func.sh | sh # Gitlab CI badges creation: END artifacts: diff --git a/gitlab-ci-requirements.txt b/gitlab-ci-requirements.txt index 7a8109a..32c327d 100644 --- a/gitlab-ci-requirements.txt +++ b/gitlab-ci-requirements.txt @@ -11,8 +11,6 @@ pytest pytest-cov pytest-pylint python-dotenv>=0.5.1 -requests # from pyorbital -scipy # from pyorbital setuptools sgp4 sphinx From a45469273dd9419b8f940c893ade211d4f875235 Mon Sep 17 00:00:00 2001 From: Ludwig Schwardt Date: Tue, 7 Jul 2020 11:31:46 +0200 Subject: [PATCH 018/122] Finesse the various requirements.txt files The SARAO Jenkins files (requirements.txt and test-requirements.txt) need recursive enumeration of all requirements, with pinning of any packages not in the Docker base requirements. Thankfully pytest is already in the base. The system-requirements.txt is presumably a SARAO CAM thing. Update it for good measure (it does not need recursion). --- requirements.txt | 8 ++++---- system-requirements.txt | 11 +++++++---- test-requirements.txt | 11 +++++++++++ 3 files changed, 22 insertions(+), 8 deletions(-) diff --git a/requirements.txt b/requirements.txt index c32c2dc..7bfcf7d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,6 @@ astropy numpy -pyorbital -requests # from pyorbital -scipy # from pyorbital -sgp4 +pyorbital==1.5.0 +requests # via pyorbital +scipy # via pyorbital +sgp4==2.4 diff --git a/system-requirements.txt b/system-requirements.txt index c88d4d5..dbc8429 100644 --- a/system-requirements.txt +++ b/system-requirements.txt @@ -1,6 +1,9 @@ -coverage -nose -nosexcover +astropy numpy +pyorbital +pytest +pytest-cov +requests +scipy +sgp4 virtualenv - diff --git a/test-requirements.txt b/test-requirements.txt index 9955dec..9eb8444 100644 --- a/test-requirements.txt +++ b/test-requirements.txt @@ -1,2 +1,13 @@ +attrs # via pytest +coverage # via pytest-cov +importlib-metadata # via pytest +more-itertools # via pytest +packaging # via pytest +pluggy # via pytest +py # via pytest +pyparsing # via packaging pytest pytest-cov +six # via packaging +wcwidth # via pytest +zipp # via importlib-metadata From 1d80f55f12b54e96561619044e6001ac2a218df1 Mon Sep 17 00:00:00 2001 From: Ludwig Schwardt Date: Tue, 7 Jul 2020 11:42:35 +0200 Subject: [PATCH 019/122] Consolidate test-skipping decorator --- katpoint/test/test_projection.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/katpoint/test/test_projection.py b/katpoint/test/test_projection.py index 9f06a94..996ea24 100644 --- a/katpoint/test/test_projection.py +++ b/katpoint/test/test_projection.py @@ -26,6 +26,7 @@ HAS_AIPS = True except ImportError: HAS_AIPS = False +require_aips = pytest.mark.skipif(not HAS_AIPS, reason="AIPS projection module not found") def assert_angles_almost_equal(x, y, decimal): @@ -61,7 +62,7 @@ def test_random_closure(self): assert_angles_almost_equal(az, aa, decimal=10) assert_angles_almost_equal(el, ee, decimal=10) - @pytest.mark.skipif(not HAS_AIPS, reason="AIPS projection module not found") + @require_aips def test_aips_compatibility(self): """SIN projection: compare with original AIPS routine.""" az, el = self.plane_to_sphere(self.az0, self.el0, self.x, self.y) @@ -166,7 +167,7 @@ def test_random_closure(self): assert_angles_almost_equal(az, aa, decimal=8) assert_angles_almost_equal(el, ee, decimal=8) - @pytest.mark.skipif(not HAS_AIPS, reason="AIPS projection module not found") + @require_aips def test_aips_compatibility(self): """TAN projection: compare with original AIPS routine.""" # AIPS TAN only deprojects (x, y) coordinates within unit circle @@ -270,7 +271,7 @@ def test_random_closure(self): assert_angles_almost_equal(az, aa, decimal=8) assert_angles_almost_equal(el, ee, decimal=8) - @ pytest.mark.skipif(not HAS_AIPS, reason="AIPS projection module not found") + @require_aips def test_aips_compatibility(self): """ARC projection: compare with original AIPS routine.""" az, el = self.plane_to_sphere(self.az0, self.el0, self.x, self.y) @@ -386,7 +387,7 @@ def test_random_closure(self): assert_angles_almost_equal(az, aa, decimal=9) assert_angles_almost_equal(el, ee, decimal=9) - @ pytest.mark.skipif(not HAS_AIPS, reason="AIPS projection module not found") + @require_aips def test_aips_compatibility(self): """STG projection: compare with original AIPS routine.""" az, el = self.plane_to_sphere(self.az0, self.el0, self.x, self.y) From f358e12a15304a720a94b1b38c2b17eb9ef3a55c Mon Sep 17 00:00:00 2001 From: Ludwig Schwardt Date: Thu, 9 Jul 2020 11:21:47 +0200 Subject: [PATCH 020/122] Construct list directly and not via appends --- katpoint/test/test_model.py | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/katpoint/test/test_model.py b/katpoint/test/test_model.py index c7bb349..0a571b5 100644 --- a/katpoint/test/test_model.py +++ b/katpoint/test/test_model.py @@ -28,14 +28,12 @@ class TestModel: def new_params(self): """Generate fresh set of parameters (otherwise models share the same ones).""" - params = [] - params.append(katpoint.Parameter('POS_E', 'm', 'East', value=10.0)) - params.append(katpoint.Parameter('POS_N', 'm', 'North', value=-9.0)) - params.append(katpoint.Parameter('POS_U', 'm', 'Up', value=3.0)) - params.append(katpoint.Parameter('NIAO', 'm', 'non-inter', value=0.88)) - params.append(katpoint.Parameter('CAB_H', '', 'horizontal', value=20.2)) - params.append(katpoint.Parameter('CAB_V', 'deg', 'vertical', value=20.3)) - return params + return [katpoint.Parameter('POS_E', 'm', 'East', value=10.0), + katpoint.Parameter('POS_N', 'm', 'North', value=-9.0), + katpoint.Parameter('POS_U', 'm', 'Up', value=3.0), + katpoint.Parameter('NIAO', 'm', 'non-inter', value=0.88), + katpoint.Parameter('CAB_H', '', 'horizontal', value=20.2), + katpoint.Parameter('CAB_V', 'deg', 'vertical', value=20.3)] def test_construct_save_load(self): """Test construction / save / load of generic model.""" From f653554d3c035402ad81910d712f71ded434af49 Mon Sep 17 00:00:00 2001 From: Ludwig Schwardt Date: Thu, 9 Jul 2020 11:25:53 +0200 Subject: [PATCH 021/122] Move custom assert to helper module This helper function was defined in five test modules. Remove the duplication by sharing the definition from a new helper module. Register the new module for pytest assert rewriting, even though it uses the un-rewritten np.testing internally. --- katpoint/test/__init__.py | 3 +++ katpoint/test/helper.py | 25 +++++++++++++++++++++++++ katpoint/test/test_antenna.py | 6 +----- katpoint/test/test_conversion.py | 6 +----- katpoint/test/test_pointing.py | 6 +----- katpoint/test/test_projection.py | 8 ++------ katpoint/test/test_refraction.py | 6 +----- 7 files changed, 34 insertions(+), 26 deletions(-) create mode 100644 katpoint/test/helper.py diff --git a/katpoint/test/__init__.py b/katpoint/test/__init__.py index e69de29..2627ed8 100644 --- a/katpoint/test/__init__.py +++ b/katpoint/test/__init__.py @@ -0,0 +1,3 @@ +import pytest + +pytest.register_assert_rewrite("katpoint.test.helper") diff --git a/katpoint/test/helper.py b/katpoint/test/helper.py new file mode 100644 index 0000000..d5dee2b --- /dev/null +++ b/katpoint/test/helper.py @@ -0,0 +1,25 @@ +################################################################################ +# Copyright (c) 2009-2020, National Research Foundation (SARAO) +# +# Licensed under the BSD 3-Clause License (the "License"); you may not use +# this file except in compliance with the License. You may obtain a copy +# of the License at +# +# https://opensource.org/licenses/BSD-3-Clause +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +################################################################################ + +"""Shared pytest utilities.""" + +import numpy as np + + +def assert_angles_almost_equal(x, y, **kwargs): + def primary_angle(x): + return x - np.round(x / (2.0 * np.pi)) * 2.0 * np.pi + np.testing.assert_almost_equal(primary_angle(x - y), np.zeros(np.shape(x)), **kwargs) diff --git a/katpoint/test/test_antenna.py b/katpoint/test/test_antenna.py index ff15d95..ddd9968 100644 --- a/katpoint/test/test_antenna.py +++ b/katpoint/test/test_antenna.py @@ -24,11 +24,7 @@ import katpoint - -def assert_angles_almost_equal(x, y, decimal): - def primary_angle(x): - return x - np.round(x / (2.0 * np.pi)) * 2.0 * np.pi - np.testing.assert_almost_equal(primary_angle(x - y), np.zeros(np.shape(x)), decimal=decimal) +from .helper import assert_angles_almost_equal class TestAntenna: diff --git a/katpoint/test/test_conversion.py b/katpoint/test/test_conversion.py index 3371902..5f8b38b 100644 --- a/katpoint/test/test_conversion.py +++ b/katpoint/test/test_conversion.py @@ -22,11 +22,7 @@ import katpoint - -def assert_angles_almost_equal(x, y, decimal): - def primary_angle(x): - return x - np.round(x / (2.0 * np.pi)) * 2.0 * np.pi - np.testing.assert_almost_equal(primary_angle(x - y), np.zeros(np.shape(x)), decimal=decimal) +from .helper import assert_angles_almost_equal class TestGeodetic: diff --git a/katpoint/test/test_pointing.py b/katpoint/test/test_pointing.py index df2b12b..77a8442 100644 --- a/katpoint/test/test_pointing.py +++ b/katpoint/test/test_pointing.py @@ -21,11 +21,7 @@ import katpoint - -def assert_angles_almost_equal(x, y, **kwargs): - def primary_angle(x): - return x - np.round(x / (2.0 * np.pi)) * 2.0 * np.pi - np.testing.assert_almost_equal(primary_angle(x - y), np.zeros(np.shape(x)), **kwargs) +from .helper import assert_angles_almost_equal class TestPointingModel: diff --git a/katpoint/test/test_projection.py b/katpoint/test/test_projection.py index 996ea24..139c146 100644 --- a/katpoint/test/test_projection.py +++ b/katpoint/test/test_projection.py @@ -21,6 +21,8 @@ import katpoint +from .helper import assert_angles_almost_equal + try: from .aips_projection import newpos, dircos HAS_AIPS = True @@ -29,12 +31,6 @@ require_aips = pytest.mark.skipif(not HAS_AIPS, reason="AIPS projection module not found") -def assert_angles_almost_equal(x, y, decimal): - def primary_angle(x): - return x - np.round(x / (2.0 * np.pi)) * 2.0 * np.pi - np.testing.assert_almost_equal(primary_angle(x - y), np.zeros(np.shape(x)), decimal=decimal) - - class TestProjectionSIN: """Test orthographic projection.""" diff --git a/katpoint/test/test_refraction.py b/katpoint/test/test_refraction.py index 60c75ed..b6cbf28 100644 --- a/katpoint/test/test_refraction.py +++ b/katpoint/test/test_refraction.py @@ -21,11 +21,7 @@ import katpoint - -def assert_angles_almost_equal(x, y, **kwargs): - def primary_angle(x): - return x - np.round(x / (2.0 * np.pi)) * 2.0 * np.pi - np.testing.assert_almost_equal(primary_angle(x - y), np.zeros(np.shape(x)), **kwargs) +from .helper import assert_angles_almost_equal class TestRefractionCorrection: From 8fde45755bb80ae9dde082fe3165f5f56126779e Mon Sep 17 00:00:00 2001 From: Ludwig Schwardt Date: Fri, 10 Jul 2020 23:27:56 +0200 Subject: [PATCH 022/122] Turn test_star into a proper test --- katpoint/test/test_stars.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/katpoint/test/test_stars.py b/katpoint/test/test_stars.py index 39f7156..ba10b9e 100644 --- a/katpoint/test/test_stars.py +++ b/katpoint/test/test_stars.py @@ -19,6 +19,7 @@ import numpy as np from katpoint.stars import readdb +from katpoint.bodies import EarthSatellite, FixedBody def test_earth_satellite(): @@ -26,6 +27,7 @@ def test_earth_satellite(): '55.4408,61.379002,0.0191986,78.180199,283.9935,2.0056172,1.2e-07,10428,9.9999997e-05' e = readdb(record) + assert isinstance(e, EarthSatellite) assert e.name == 'GPS BIIA-21 (PR' assert str(e._epoch) == '2019-09-23 07:45:35.842' assert e._inc == np.deg2rad(55.4408) @@ -41,4 +43,8 @@ def test_earth_satellite(): def test_star(): record = 'Sadr,f|S|F8,20:22:13.7|2.43,40:15:24|-0.93,2.23,2000,0' - readdb(record) + e = readdb(record) + assert isinstance(e, FixedBody) + assert e.name == 'Sadr' + assert e._radec.ra.to_string(sep=':', unit='hour') == '20:22:13.7' + assert e._radec.dec.to_string(sep=':', unit='deg') == '40:15:24' From 27387a178b3784b72e5d5b7b482487c0aeaf3754 Mon Sep 17 00:00:00 2001 From: Ludwig Schwardt Date: Fri, 10 Jul 2020 23:31:22 +0200 Subject: [PATCH 023/122] Switch to pytest parametrization Do a complete rewrite of test_projection.py, swapping test classes for parametrized functions. To help this process, split out test data generators for each projection. The tests are now organised per test type (random closure, AIPS checks, etc) instead of per projection type. Use parametrization in test_target and test_body too. --- katpoint/test/test_body.py | 128 ++--- katpoint/test/test_projection.py | 812 +++++++++++-------------------- katpoint/test/test_target.py | 129 ++--- 3 files changed, 405 insertions(+), 664 deletions(-) diff --git a/katpoint/test/test_body.py b/katpoint/test/test_body.py index 21937e1..bb9eccb 100644 --- a/katpoint/test/test_body.py +++ b/katpoint/test/test_body.py @@ -26,76 +26,63 @@ import astropy.units as u from astropy.coordinates import SkyCoord, ICRS, EarthLocation, Latitude, Longitude from astropy.time import Time - -from katpoint.bodies import FixedBody, Sun, Moon, Mars, readtle - - -class TestFixedBody: - """Test for the FixedBody class.""" - - def test_compute(self): +import pytest + +from katpoint.bodies import FixedBody, Sun, Moon, Mars, EarthSatellite, readtle + + +def _get_earth_satellite(): + name = ' GPS BIIA-21 (PRN 09) ' + line1 = '1 22700U 93042A 19266.32333151 .00000012 00000-0 10000-3 0 8057' + line2 = '2 22700 55.4408 61.3790 0191986 78.1802 283.9935 2.00561720104282' + return readtle(name, line1, line2) + + +class TestBody: + """Test computing with various Bodies.""" + + @pytest.mark.parametrize( + "body, date_str, ra_str, dec_str, az_str, el_str", + [ + (FixedBody(), '2020-01-01 00:00:00.000', + '10:10:40.123', '40:20:50.567', '326:05:57.541', '51:21:20.0119'), + # 326:05:54.8, 51:21:18.5 (PyEphem) + (Mars(), '2020-01-01 00:00:00.000', + '', '', '118:10:05.1129', '27:23:12.8499'), + # 118:10:06.1, 27:23:13.3 (PyEphem) + (Moon(), '2020-01-01 10:00:00.000', + '', '', '127:15:17.1381', '60:05:10.2438'), + # 127:15:23.6, 60:05:13.7 (PyEphem) + (Sun(), '2020-01-01 10:00:00.000', + '', '', '234:53:19.4835', '31:38:11.412'), + # 234:53:20.8, 31:38:09.4 (PyEphem) + (_get_earth_satellite(), '2019-09-23 07:45:36.000', + '3:32:56.7813', '-2:04:35.4329', '280:32:29.675', '-54:06:50.7456'), + # 3:32:59.21 -2:04:36.3 280:32:07.2 -54:06:14.4 (PyEphem) + ] + ) + def test_compute(self, body, date_str, ra_str, dec_str, az_str, el_str): """Test compute method""" lat = Latitude('10:00:00.000', unit=u.deg) lon = Longitude('80:00:00.000', unit=u.deg) - date = Time('2020-01-01 00:00:00.000') - - ra = Longitude('10:10:40.123', unit=u.hour) - dec = Latitude('40:20:50.567', unit=u.deg) - body = FixedBody() - body._radec = SkyCoord(ra=ra, dec=dec, frame=ICRS) - body.compute(EarthLocation(lat=lat, lon=lon, height=0.0), date, 0.0) - - assert body.a_radec.ra.to_string(sep=':', unit=u.hour) == '10:10:40.123' - assert body.a_radec.dec.to_string(sep=':') == '40:20:50.567' - - # 326:05:54.8 51:21:18.5 - assert body.altaz.az.to_string(sep=':') == '326:05:57.541' - assert body.altaz.alt.to_string(sep=':') == '51:21:20.0119' - - def test_planet(self): - lat = Latitude('10:00:00.000', unit=u.deg) - lon = Longitude('80:00:00.000', unit=u.deg) - date = Time('2020-01-01 00:00:00.000') - - body = Mars() - body.compute(EarthLocation(lat=lat, lon=lon, height=0.0), date, 0.0) - - # '118:10:06.1' '27:23:13.3' - assert body.altaz.az.to_string(sep=':') == '118:10:05.1129' - assert body.altaz.alt.to_string(sep=':') == '27:23:12.8499' - - def test_moon(self): - lat = Latitude('10:00:00.000', unit=u.deg) - lon = Longitude('80:00:00.000', unit=u.deg) - date = Time('2020-01-01 10:00:00.000') - - body = Moon() - body.compute(EarthLocation(lat=lat, lon=lon, height=0.0), date, 0.0) - - # 127:15:23.6 60:05:13.7' - assert body.altaz.az.to_string(sep=':') == '127:15:17.1381' - assert body.altaz.alt.to_string(sep=':') == '60:05:10.2438' - - def test_sun(self): - lat = Latitude('10:00:00.000', unit=u.deg) - lon = Longitude('80:00:00.000', unit=u.deg) - date = Time('2020-01-01 10:00:00.000') + date = Time(date_str) - body = Sun() - body.compute(EarthLocation(lat=lat, lon=lon, height=0.0), date, 0.0) + if isinstance(body, FixedBody): + ra = Longitude(ra_str, unit=u.hour) + dec = Latitude(dec_str, unit=u.deg) + body._radec = SkyCoord(ra=ra, dec=dec, frame=ICRS) + height = 4200.0 if isinstance(body, EarthSatellite) else 0.0 + body.compute(EarthLocation(lat=lat, lon=lon, height=height), date, 0.0) - # 234:53:20.8 '31:38:09.4' - assert body.altaz.az.to_string(sep=':') == '234:53:19.4835' - assert body.altaz.alt.to_string(sep=':') == '31:38:11.412' + if ra_str and dec_str: + assert body.a_radec.ra.to_string(sep=':', unit=u.hour) == ra_str + assert body.a_radec.dec.to_string(sep=':') == dec_str + assert body.altaz.az.to_string(sep=':') == az_str + assert body.altaz.alt.to_string(sep=':') == el_str def test_earth_satellite(self): - name = ' GPS BIIA-21 (PRN 09) ' - line1 = '1 22700U 93042A 19266.32333151 .00000012 00000-0 10000-3 0 8057' - line2 = '2 22700 55.4408 61.3790 0191986 78.1802 283.9935 2.00561720104282' - sat = readtle(name, line1, line2) - - # Check that the EarthSatellite object has the expect attribute - # values. + sat = _get_earth_satellite() + # Check that the EarthSatellite object has the expect attribute values. assert str(sat._epoch) == '2019-09-23 07:45:35.842' assert sat._inc == np.deg2rad(55.4408) assert sat._raan == np.deg2rad(61.3790) @@ -147,18 +134,3 @@ def test_earth_satellite(self): assert rec.split(',')[8] == xephem.split(',')[8] assert rec.split(',')[9] == xephem.split(',')[9] assert rec.split(',')[10] == xephem.split(',')[10] - - # Test compute - lat = Latitude('10:00:00.000', unit=u.deg) - lon = Longitude('80:00:00.000', unit=u.deg) - date = Time('2019-09-23 07:45:36.000') - elevation = 4200.0 - sat.compute(EarthLocation(lat=lat, lon=lon, height=elevation), date, 0.0) - - # 3:32:59.21' '-2:04:36.3' - assert sat.a_radec.ra.to_string(sep=':', unit=u.hour) == '3:32:56.7813' - assert sat.a_radec.dec.to_string(sep=':') == '-2:04:35.4329' - - # 280:32:07.2 -54:06:14.4 - assert sat.altaz.az.to_string(sep=':') == '280:32:29.675' - assert sat.altaz.alt.to_string(sep=':') == '-54:06:50.7456' diff --git a/katpoint/test/test_projection.py b/katpoint/test/test_projection.py index 139c146..f6bbda4 100644 --- a/katpoint/test/test_projection.py +++ b/katpoint/test/test_projection.py @@ -17,6 +17,7 @@ """Tests for the projection module.""" import numpy as np +from numpy import pi as PI # Unorthodox but shortens those parametrization lines a lot import pytest import katpoint @@ -28,457 +29,295 @@ HAS_AIPS = True except ImportError: HAS_AIPS = False -require_aips = pytest.mark.skipif(not HAS_AIPS, reason="AIPS projection module not found") -class TestProjectionSIN: - """Test orthographic projection.""" - - def setup(self): - self.plane_to_sphere = katpoint.plane_to_sphere['SIN'] - self.sphere_to_plane = katpoint.sphere_to_plane['SIN'] - N = 100 - max_theta = np.pi / 2.0 - self.az0 = np.pi * (2.0 * np.random.rand(N) - 1.0) +def random_sphere(N, include_poles=False): + az = PI * (2.0 * np.random.rand(N) - 1.0) + el = PI * (np.random.rand(N) - 0.5) + if not include_poles: # Keep away from poles (leave them as corner cases) - self.el0 = 0.999 * np.pi * (np.random.rand(N) - 0.5) - # (x, y) points within unit circle - theta = max_theta * np.random.rand(N) - phi = 2 * np.pi * np.random.rand(N) - self.x = np.sin(theta) * np.cos(phi) - self.y = np.sin(theta) * np.sin(phi) - - def test_random_closure(self): - """SIN projection: do random projections and check closure.""" - az, el = self.plane_to_sphere(self.az0, self.el0, self.x, self.y) - xx, yy = self.sphere_to_plane(self.az0, self.el0, az, el) - aa, ee = self.plane_to_sphere(self.az0, self.el0, xx, yy) - np.testing.assert_almost_equal(self.x, xx, decimal=10) - np.testing.assert_almost_equal(self.y, yy, decimal=10) - assert_angles_almost_equal(az, aa, decimal=10) - assert_angles_almost_equal(el, ee, decimal=10) - - @require_aips - def test_aips_compatibility(self): - """SIN projection: compare with original AIPS routine.""" - az, el = self.plane_to_sphere(self.az0, self.el0, self.x, self.y) - xx, yy = self.sphere_to_plane(self.az0, self.el0, az, el) - az_aips, el_aips = np.zeros(az.shape), np.zeros(el.shape) - x_aips, y_aips = np.zeros(xx.shape), np.zeros(yy.shape) - for n in range(len(az)): - az_aips[n], el_aips[n], ierr = newpos( - 2, self.az0[n], self.el0[n], self.x[n], self.y[n]) - x_aips[n], y_aips[n], ierr = dircos( - 2, self.az0[n], self.el0[n], az[n], el[n]) + el *= 0.999 + return az, el + + +def random_disk(N, radius_warp, max_theta): + theta = max_theta * np.random.rand(N) + phi = 2 * PI * np.random.rand(N) + r = radius_warp(theta) + return r * np.cos(phi), r * np.sin(phi) + + +def generate_data_sin(N): + """Generate test data for orthographic (SIN) projection.""" + az0, el0 = random_sphere(N) + # (x, y) points within unit circle + x, y = random_disk(N, np.sin, max_theta=PI/2) + return az0, el0, x, y + + +def generate_data_tan(N): + """Generate test data for gnomonic (TAN) projection.""" + az0, el0 = random_sphere(N) + # Perform inverse TAN mapping to spread out points on plane + # Stay away from edge of hemisphere + x, y = random_disk(N, np.tan, max_theta=PI/2 - 0.01) + return az0, el0, x, y + + +def generate_data_arc(N): + """Generate test data for zenithal equidistant (ARC) projection.""" + az0, el0 = random_sphere(N) + # (x, y) points within circle of radius pi + # Stay away from edge of circle + x, y = random_disk(N, lambda theta: theta, max_theta=PI - 0.01) + return az0, el0, x, y + + +def generate_data_stg(N): + """Generate test data for stereographic (STG) projection.""" + az0, el0 = random_sphere(N) + # Perform inverse STG mapping to spread out points on plane + # Stay well away from point of projection + x, y = random_disk(N, lambda theta: 2.0 * np.sin(theta) / (1.0 + np.cos(theta)), + max_theta=0.8 * PI) + return az0, el0, x, y + + +def generate_data_car(N): + """Generate test data for plate carree (CAR) projection.""" + # Unrestricted (az0, el0) points on sphere + az0, el0 = random_sphere(N, include_poles=True) + # Unrestricted (x, y) points on corresponding plane + x, y = random_sphere(N, include_poles=True) + return az0, el0, x, y + + +def generate_data_ssn(N): + """Generate test data for swapped orthographic (SSN) projection.""" + az0, el0 = random_sphere(N) + # (x, y) points within complicated SSN domain - clipped unit circle + cos_el0 = np.cos(el0) + # The x coordinate is bounded by +- cos(el0) + x = (2.0 * np.random.rand(N) - 1.0) * cos_el0 + # The y coordinate ranges between two (semi-)circles centred on origin: + # the unit circle on one side and circle of radius cos(el0) on other side + y_offset = -np.sqrt(cos_el0 ** 2 - x ** 2) + y_range = -y_offset + np.sqrt(1.0 - x ** 2) + y = (y_range * np.random.rand(N) + y_offset) * np.sign(el0) + return az0, el0, x, y + + +generate_data = {'SIN': generate_data_sin, 'TAN': generate_data_tan, + 'ARC': generate_data_arc, 'STG': generate_data_stg, + 'CAR': generate_data_car, 'SSN': generate_data_ssn} + + +@pytest.mark.parametrize('projection, decimal', + [('SIN', 10), ('TAN', 8), ('ARC', 8), + ('STG', 9), ('CAR', 12), ('SSN', 10)]) +def test_random_closure(projection, decimal, N=100): + """Do random projections and check closure.""" + plane_to_sphere = katpoint.plane_to_sphere[projection] + sphere_to_plane = katpoint.sphere_to_plane[projection] + az0, el0, x, y = generate_data[projection](N) + az, el = plane_to_sphere(az0, el0, x, y) + xx, yy = sphere_to_plane(az0, el0, az, el) + aa, ee = plane_to_sphere(az0, el0, xx, yy) + np.testing.assert_almost_equal(x, xx, decimal=decimal) + np.testing.assert_almost_equal(y, yy, decimal=decimal) + assert_angles_almost_equal(az, aa, decimal=decimal) + assert_angles_almost_equal(el, ee, decimal=decimal) + + +@pytest.mark.skipif(not HAS_AIPS, reason="AIPS projection module not found") +@pytest.mark.parametrize('projection, aips_code, decimal', + [('SIN', 2, 9), ('TAN', 3, 10), ('ARC', 4, 8), ('STG', 6, 9)]) +def test_aips_compatibility(projection, aips_code, decimal, N=100): + """Compare with original AIPS routine (if available).""" + plane_to_sphere = katpoint.plane_to_sphere[projection] + sphere_to_plane = katpoint.sphere_to_plane[projection] + az0, el0, x, y = generate_data[projection](N) + if projection == 'TAN': + # AIPS TAN only deprojects (x, y) coordinates within unit circle + r = x * x + y * y + az0, el0 = az0[r <= 1.0], el0[r <= 1.0] + x, y = x[r <= 1.0], y[r <= 1.0] + az, el = plane_to_sphere(az0, el0, x, y) + xx, yy = sphere_to_plane(az0, el0, az, el) + az_aips, el_aips = np.zeros_like(az), np.zeros_like(el) + x_aips, y_aips = np.zeros_like(xx), np.zeros_like(yy) + for n in range(len(az)): + az_aips[n], el_aips[n], ierr = newpos(aips_code, az0[n], el0[n], x[n], y[n]) assert ierr == 0 - assert_angles_almost_equal(az, az_aips, decimal=9) - assert_angles_almost_equal(el, el_aips, decimal=9) - np.testing.assert_almost_equal(xx, x_aips, decimal=9) - np.testing.assert_almost_equal(yy, y_aips, decimal=9) - - def test_corner_cases(self): - """SIN projection: test special corner cases.""" - # SPHERE TO PLANE - # Origin - xy = np.array(self.sphere_to_plane(0.0, 0.0, 0.0, 0.0)) - np.testing.assert_almost_equal(xy, [0.0, 0.0], decimal=12) - # Points 90 degrees from reference point on sphere - xy = np.array(self.sphere_to_plane(0.0, 0.0, np.pi / 2.0, 0.0)) - np.testing.assert_almost_equal(xy, [1.0, 0.0], decimal=12) - xy = np.array(self.sphere_to_plane(0.0, 0.0, -np.pi / 2.0, 0.0)) - np.testing.assert_almost_equal(xy, [-1.0, 0.0], decimal=12) - xy = np.array(self.sphere_to_plane(0.0, 0.0, 0.0, np.pi / 2.0)) - np.testing.assert_almost_equal(xy, [0.0, 1.0], decimal=12) - xy = np.array(self.sphere_to_plane(0.0, 0.0, 0.0, -np.pi / 2.0)) - np.testing.assert_almost_equal(xy, [0.0, -1.0], decimal=12) - # Reference point at pole on sphere - xy = np.array(self.sphere_to_plane(0.0, np.pi / 2.0, 0.0, 0.0)) - np.testing.assert_almost_equal(xy, [0.0, -1.0], decimal=12) - xy = np.array(self.sphere_to_plane(0.0, np.pi / 2.0, np.pi, 1e-8)) - np.testing.assert_almost_equal(xy, [0.0, 1.0], decimal=12) - xy = np.array(self.sphere_to_plane(0.0, np.pi / 2.0, np.pi / 2.0, 0.0)) - np.testing.assert_almost_equal(xy, [1.0, 0.0], decimal=12) - xy = np.array(self.sphere_to_plane(0.0, np.pi / 2.0, -np.pi / 2.0, 0.0)) - np.testing.assert_almost_equal(xy, [-1.0, 0.0], decimal=12) - # Points outside allowed domain on sphere - with pytest.raises(ValueError): - self.sphere_to_plane(0.0, 0.0, np.pi, 0.0) - with pytest.raises(ValueError): - self.sphere_to_plane(0.0, 0.0, 0.0, np.pi) - - # PLANE TO SPHERE - # Origin - ae = np.array(self.plane_to_sphere(0.0, 0.0, 0.0, 0.0)) - assert_angles_almost_equal(ae, [0.0, 0.0], decimal=12) - # Points on unit circle in plane - ae = np.array(self.plane_to_sphere(0.0, 0.0, 1.0, 0.0)) - assert_angles_almost_equal(ae, [np.pi / 2.0, 0.0], decimal=12) - ae = np.array(self.plane_to_sphere(0.0, 0.0, -1.0, 0.0)) - assert_angles_almost_equal(ae, [-np.pi / 2.0, 0.0], decimal=12) - ae = np.array(self.plane_to_sphere(0.0, 0.0, 0.0, 1.0)) - assert_angles_almost_equal(ae, [0.0, np.pi / 2.0], decimal=12) - ae = np.array(self.plane_to_sphere(0.0, 0.0, 0.0, -1.0)) - assert_angles_almost_equal(ae, [0.0, -np.pi / 2.0], decimal=12) - # Reference point at pole on sphere - ae = np.array(self.plane_to_sphere(0.0, -np.pi / 2.0, 1.0, 0.0)) - assert_angles_almost_equal(ae, [np.pi / 2.0, 0.0], decimal=12) - ae = np.array(self.plane_to_sphere(0.0, -np.pi / 2.0, -1.0, 0.0)) - assert_angles_almost_equal(ae, [-np.pi / 2.0, 0.0], decimal=12) - ae = np.array(self.plane_to_sphere(0.0, -np.pi / 2.0, 0.0, 1.0)) - assert_angles_almost_equal(ae, [0.0, 0.0], decimal=12) - ae = np.array(self.plane_to_sphere(0.0, -np.pi / 2.0, 0.0, -1.0)) - assert_angles_almost_equal(ae, [np.pi, 0.0], decimal=12) - # Points outside allowed domain in plane - with pytest.raises(ValueError): - self.plane_to_sphere(0.0, 0.0, 2.0, 0.0) - with pytest.raises(ValueError): - self.plane_to_sphere(0.0, 0.0, 0.0, 2.0) - + x_aips[n], y_aips[n], ierr = dircos(aips_code, az0[n], el0[n], az[n], el[n]) + assert ierr == 0 + # AIPS NEWPOS STG has poor accuracy on azimuth angle (large closure errors by itself) + if projection != 'STG': + assert_angles_almost_equal(az, az_aips, decimal=decimal) + assert_angles_almost_equal(el, el_aips, decimal=decimal) + np.testing.assert_almost_equal(xx, x_aips, decimal=decimal) + np.testing.assert_almost_equal(yy, y_aips, decimal=decimal) -class TestProjectionTAN: - """Test gnomonic projection.""" - def setup(self): - self.plane_to_sphere = katpoint.plane_to_sphere['TAN'] - self.sphere_to_plane = katpoint.sphere_to_plane['TAN'] - N = 100 - # Stay away from edge of hemisphere - max_theta = np.pi / 2.0 - 0.01 - self.az0 = np.pi * (2.0 * np.random.rand(N) - 1.0) - # Keep away from poles (leave them as corner cases) - self.el0 = 0.999 * np.pi * (np.random.rand(N) - 0.5) - theta = max_theta * np.random.rand(N) - phi = 2 * np.pi * np.random.rand(N) - # Perform inverse TAN mapping to spread out points on plane - self.x = np.tan(theta) * np.cos(phi) - self.y = np.tan(theta) * np.sin(phi) - - def test_random_closure(self): - """TAN projection: do random projections and check closure.""" - az, el = self.plane_to_sphere(self.az0, self.el0, self.x, self.y) - xx, yy = self.sphere_to_plane(self.az0, self.el0, az, el) - aa, ee = self.plane_to_sphere(self.az0, self.el0, xx, yy) - np.testing.assert_almost_equal(self.x, xx, decimal=8) - np.testing.assert_almost_equal(self.y, yy, decimal=8) - assert_angles_almost_equal(az, aa, decimal=8) - assert_angles_almost_equal(el, ee, decimal=8) - - @require_aips - def test_aips_compatibility(self): - """TAN projection: compare with original AIPS routine.""" - # AIPS TAN only deprojects (x, y) coordinates within unit circle - r = self.x * self.x + self.y * self.y - az0, el0 = self.az0[r <= 1.0], self.el0[r <= 1.0] - x, y = self.x[r <= 1.0], self.y[r <= 1.0] - az, el = self.plane_to_sphere(az0, el0, x, y) - xx, yy = self.sphere_to_plane(az0, el0, az, el) - az_aips, el_aips = np.zeros(az.shape), np.zeros(el.shape) - x_aips, y_aips = np.zeros(xx.shape), np.zeros(yy.shape) - for n in range(len(az)): - az_aips[n], el_aips[n], ierr = newpos( - 3, az0[n], el0[n], x[n], y[n]) - x_aips[n], y_aips[n], ierr = dircos( - 3, az0[n], el0[n], az[n], el[n]) - assert ierr == 0 - assert_angles_almost_equal(az, az_aips, decimal=10) - assert_angles_almost_equal(el, el_aips, decimal=10) - np.testing.assert_almost_equal(xx, x_aips, decimal=10) - np.testing.assert_almost_equal(yy, y_aips, decimal=10) - - def test_corner_cases(self): - """TAN projection: test special corner cases.""" - # SPHERE TO PLANE +@pytest.mark.parametrize( + "projection, sphere, plane", + [ # Origin - xy = np.array(self.sphere_to_plane(0.0, 0.0, 0.0, 0.0)) - np.testing.assert_almost_equal(xy, [0.0, 0.0], decimal=12) + ('SIN', (0.0, 0.0, 0.0, 0.0), [0.0, 0.0]), + ('TAN', (0.0, 0.0, 0.0, 0.0), [0.0, 0.0]), + ('ARC', (0.0, 0.0, 0.0, 0.0), [0.0, 0.0]), + ('STG', (0.0, 0.0, 0.0, 0.0), [0.0, 0.0]), + ('SSN', (0.0, 0.0, 0.0, 0.0), [0.0, 0.0]), # Points 45 degrees from reference point on sphere - xy = np.array(self.sphere_to_plane(0.0, 0.0, np.pi / 4.0, 0.0)) - np.testing.assert_almost_equal(xy, [1.0, 0.0], decimal=12) - xy = np.array(self.sphere_to_plane(0.0, 0.0, -np.pi / 4.0, 0.0)) - np.testing.assert_almost_equal(xy, [-1.0, 0.0], decimal=12) - xy = np.array(self.sphere_to_plane(0.0, 0.0, 0.0, np.pi / 4.0)) - np.testing.assert_almost_equal(xy, [0.0, 1.0], decimal=12) - xy = np.array(self.sphere_to_plane(0.0, 0.0, 0.0, -np.pi / 4.0)) - np.testing.assert_almost_equal(xy, [0.0, -1.0], decimal=12) - # Reference point at pole on sphere - xy = np.array(self.sphere_to_plane(0.0, np.pi / 2.0, 0.0, np.pi / 4.0)) - np.testing.assert_almost_equal(xy, [0.0, -1.0], decimal=12) - xy = np.array(self.sphere_to_plane(0.0, np.pi / 2.0, np.pi, np.pi / 4.0)) - np.testing.assert_almost_equal(xy, [0.0, 1.0], decimal=12) - xy = np.array(self.sphere_to_plane(0.0, np.pi / 2.0, np.pi / 2.0, np.pi / 4.0)) - np.testing.assert_almost_equal(xy, [1.0, 0.0], decimal=12) - xy = np.array(self.sphere_to_plane(0.0, np.pi / 2.0, -np.pi / 2.0, np.pi / 4.0)) - np.testing.assert_almost_equal(xy, [-1.0, 0.0], decimal=12) - # Points outside allowed domain on sphere - with pytest.raises(ValueError): - self.sphere_to_plane(0.0, 0.0, np.pi, 0.0) - with pytest.raises(ValueError): - self.sphere_to_plane(0.0, 0.0, 0.0, np.pi) - - # PLANE TO SPHERE - # Origin - ae = np.array(self.plane_to_sphere(0.0, 0.0, 0.0, 0.0)) - assert_angles_almost_equal(ae, [0.0, 0.0], decimal=12) - # Points on unit circle in plane - ae = np.array(self.plane_to_sphere(0.0, 0.0, 1.0, 0.0)) - assert_angles_almost_equal(ae, [np.pi / 4.0, 0.0], decimal=12) - ae = np.array(self.plane_to_sphere(0.0, 0.0, -1.0, 0.0)) - assert_angles_almost_equal(ae, [-np.pi / 4.0, 0.0], decimal=12) - ae = np.array(self.plane_to_sphere(0.0, 0.0, 0.0, 1.0)) - assert_angles_almost_equal(ae, [0.0, np.pi / 4.0], decimal=12) - ae = np.array(self.plane_to_sphere(0.0, 0.0, 0.0, -1.0)) - assert_angles_almost_equal(ae, [0.0, -np.pi / 4.0], decimal=12) - # Reference point at pole on sphere - ae = np.array(self.plane_to_sphere(0.0, -np.pi / 2.0, 1.0, 0.0)) - assert_angles_almost_equal(ae, [np.pi / 2.0, -np.pi / 4.0], decimal=12) - ae = np.array(self.plane_to_sphere(0.0, -np.pi / 2.0, -1.0, 0.0)) - assert_angles_almost_equal(ae, [-np.pi / 2.0, -np.pi / 4.0], decimal=12) - ae = np.array(self.plane_to_sphere(0.0, -np.pi / 2.0, 0.0, 1.0)) - assert_angles_almost_equal(ae, [0.0, -np.pi / 4.0], decimal=12) - ae = np.array(self.plane_to_sphere(0.0, -np.pi / 2.0, 0.0, -1.0)) - assert_angles_almost_equal(ae, [np.pi, -np.pi / 4.0], decimal=12) - - -class TestProjectionARC: - """Test zenithal equidistant projection.""" - - def setup(self): - self.plane_to_sphere = katpoint.plane_to_sphere['ARC'] - self.sphere_to_plane = katpoint.sphere_to_plane['ARC'] - N = 100 - # Stay away from edge of circle - max_theta = np.pi - 0.01 - self.az0 = np.pi * (2.0 * np.random.rand(N) - 1.0) - # Keep away from poles (leave them as corner cases) - self.el0 = 0.999 * np.pi * (np.random.rand(N) - 0.5) - # (x, y) points within circle of radius pi - theta = max_theta * np.random.rand(N) - phi = 2 * np.pi * np.random.rand(N) - self.x = theta * np.cos(phi) - self.y = theta * np.sin(phi) - - def test_random_closure(self): - """ARC projection: do random projections and check closure.""" - az, el = self.plane_to_sphere(self.az0, self.el0, self.x, self.y) - xx, yy = self.sphere_to_plane(self.az0, self.el0, az, el) - aa, ee = self.plane_to_sphere(self.az0, self.el0, xx, yy) - np.testing.assert_almost_equal(self.x, xx, decimal=8) - np.testing.assert_almost_equal(self.y, yy, decimal=8) - assert_angles_almost_equal(az, aa, decimal=8) - assert_angles_almost_equal(el, ee, decimal=8) - - @require_aips - def test_aips_compatibility(self): - """ARC projection: compare with original AIPS routine.""" - az, el = self.plane_to_sphere(self.az0, self.el0, self.x, self.y) - xx, yy = self.sphere_to_plane(self.az0, self.el0, az, el) - az_aips, el_aips = np.zeros(az.shape), np.zeros(el.shape) - x_aips, y_aips = np.zeros(xx.shape), np.zeros(yy.shape) - for n in range(len(az)): - az_aips[n], el_aips[n], ierr = newpos( - 4, self.az0[n], self.el0[n], self.x[n], self.y[n]) - x_aips[n], y_aips[n], ierr = dircos( - 4, self.az0[n], self.el0[n], az[n], el[n]) - assert ierr == 0 - assert_angles_almost_equal(az, az_aips, decimal=8) - assert_angles_almost_equal(el, el_aips, decimal=8) - np.testing.assert_almost_equal(xx, x_aips, decimal=8) - np.testing.assert_almost_equal(yy, y_aips, decimal=8) - - def test_corner_cases(self): - """ARC projection: test special corner cases.""" - # SPHERE TO PLANE - # Origin - xy = np.array(self.sphere_to_plane(0.0, 0.0, 0.0, 0.0)) - np.testing.assert_almost_equal(xy, [0.0, 0.0], decimal=12) + ('TAN', (0.0, 0.0, PI/4, 0.0), [1.0, 0.0]), + ('TAN', (0.0, 0.0, -PI/4, 0.0), [-1.0, 0.0]), + ('TAN', (0.0, 0.0, 0.0, PI/4), [0.0, 1.0]), + ('TAN', (0.0, 0.0, 0.0, -PI/4), [0.0, -1.0]), # Points 90 degrees from reference point on sphere - xy = np.array(self.sphere_to_plane(0.0, 0.0, np.pi / 2.0, 0.0)) - np.testing.assert_almost_equal(xy, [np.pi / 2.0, 0.0], decimal=12) - xy = np.array(self.sphere_to_plane(0.0, 0.0, -np.pi / 2.0, 0.0)) - np.testing.assert_almost_equal(xy, [-np.pi / 2.0, 0.0], decimal=12) - xy = np.array(self.sphere_to_plane(0.0, 0.0, 0.0, np.pi / 2.0)) - np.testing.assert_almost_equal(xy, [0.0, np.pi / 2.0], decimal=12) - xy = np.array(self.sphere_to_plane(0.0, 0.0, 0.0, -np.pi / 2.0)) - np.testing.assert_almost_equal(xy, [0.0, -np.pi / 2.0], decimal=12) + ('SIN', (0.0, 0.0, PI/2, 0.0), [1.0, 0.0]), + ('SIN', (0.0, 0.0, -PI/2, 0.0), [-1.0, 0.0]), + ('SIN', (0.0, 0.0, 0.0, PI/2), [0.0, 1.0]), + ('SIN', (0.0, 0.0, 0.0, -PI/2), [0.0, -1.0]), + ('ARC', (0.0, 0.0, PI/2, 0.0), [PI/2, 0.0]), + ('ARC', (0.0, 0.0, -PI/2, 0.0), [-PI/2, 0.0]), + ('ARC', (0.0, 0.0, 0.0, PI/2), [0.0, PI/2]), + ('ARC', (0.0, 0.0, 0.0, -PI/2), [0.0, -PI/2]), + ('STG', (0.0, 0.0, PI/2, 0.0), [2.0, 0.0]), + ('STG', (0.0, 0.0, -PI/2, 0.0), [-2.0, 0.0]), + ('STG', (0.0, 0.0, 0.0, PI/2), [0.0, 2.0]), + ('STG', (0.0, 0.0, 0.0, -PI/2), [0.0, -2.0]), + ('SSN', (0.0, 0.0, PI/2, 0.0), [-1.0, 0.0]), + ('SSN', (0.0, 0.0, -PI/2, 0.0), [1.0, 0.0]), + ('SSN', (0.0, 0.0, 0.0, PI/2), [0.0, -1.0]), + ('SSN', (0.0, 0.0, 0.0, -PI/2), [0.0, 1.0]), # Reference point at pole on sphere - xy = np.array(self.sphere_to_plane(0.0, np.pi / 2.0, 0.0, 0.0)) - np.testing.assert_almost_equal(xy, [0.0, -np.pi / 2.0], decimal=12) - xy = np.array(self.sphere_to_plane(0.0, np.pi / 2.0, np.pi, 0.0)) - np.testing.assert_almost_equal(xy, [0.0, np.pi / 2.0], decimal=12) - xy = np.array(self.sphere_to_plane(0.0, np.pi / 2.0, np.pi / 2.0, 0.0)) - np.testing.assert_almost_equal(xy, [np.pi / 2.0, 0.0], decimal=12) - xy = np.array(self.sphere_to_plane(0.0, np.pi / 2.0, -np.pi / 2.0, 0.0)) - np.testing.assert_almost_equal(xy, [-np.pi / 2.0, 0.0], decimal=12) - # Point diametrically opposite the reference point on sphere - xy = np.array(self.sphere_to_plane(np.pi, 0.0, 0.0, 0.0)) - np.testing.assert_almost_equal(np.abs(xy), [np.pi, 0.0], decimal=12) + ('SIN', (0.0, PI/2, 0.0, 0.0), [0.0, -1.0]), + ('SIN', (0.0, PI/2, PI, 1e-8), [0.0, 1.0]), + ('SIN', (0.0, PI/2, PI/2, 0.0), [1.0, 0.0]), + ('SIN', (0.0, PI/2, -PI/2, 0.0), [-1.0, 0.0]), + ('TAN', (0.0, PI/2, 0.0, PI/4), [0.0, -1.0]), + ('TAN', (0.0, PI/2, PI, PI/4), [0.0, 1.0]), + ('TAN', (0.0, PI/2, PI/2, PI/4), [1.0, 0.0]), + ('TAN', (0.0, PI/2, -PI/2, PI/4), [-1.0, 0.0]), + ('ARC', (0.0, PI/2, 0.0, 0.0), [0.0, -PI/2]), + ('ARC', (0.0, PI/2, PI, 0.0), [0.0, PI/2]), + ('ARC', (0.0, PI/2, PI/2, 0.0), [PI/2, 0.0]), + ('ARC', (0.0, PI/2, -PI/2, 0.0), [-PI/2, 0.0]), + ('STG', (0.0, PI/2, 0.0, 0.0), [0.0, -2.0]), + ('STG', (0.0, PI/2, PI, 0.0), [0.0, 2.0]), + ('STG', (0.0, PI/2, PI/2, 0.0), [2.0, 0.0]), + ('STG', (0.0, PI/2, -PI/2, 0.0), [-2.0, 0.0]), + ('SSN', (0.0, PI/2, 0.0, 0.0), [0.0, 1.0]), + ('SSN', (0.0, PI/2, PI, 1e-8), [0.0, 1.0]), + ('SSN', (0.0, PI/2, PI/2, 0.0), [0.0, 1.0]), + ('SSN', (0.0, PI/2, -PI/2, 0.0), [0.0, 1.0]), # Points outside allowed domain on sphere + ('SIN', (0.0, 0.0, PI, 0.0), None), + ('SIN', (0.0, 0.0, 0.0, PI), None), + ('TAN', (0.0, 0.0, PI, 0.0), None), + ('TAN', (0.0, 0.0, 0.0, PI), None), + ('ARC', (0.0, 0.0, 0.0, PI), None), + ('STG', (0.0, 0.0, PI, 0.0), None), + ('STG', (0.0, 0.0, 0.0, PI), None), + ('SSN', (0.0, 0.0, PI, 0.0), None), + ('SSN', (0.0, 0.0, 0.0, PI), None), + ] +) +def test_sphere_to_plane(projection, sphere, plane): + """Test specific cases (sphere -> plane).""" + sphere_to_plane = katpoint.sphere_to_plane[projection] + if plane is None: with pytest.raises(ValueError): - self.sphere_to_plane(0.0, 0.0, 0.0, np.pi) - - # PLANE TO SPHERE - # Origin - ae = np.array(self.plane_to_sphere(0.0, 0.0, 0.0, 0.0)) - assert_angles_almost_equal(ae, [0.0, 0.0], decimal=12) - # Points on unit circle in plane - ae = np.array(self.plane_to_sphere(0.0, 0.0, 1.0, 0.0)) - assert_angles_almost_equal(ae, [1.0, 0.0], decimal=12) - ae = np.array(self.plane_to_sphere(0.0, 0.0, -1.0, 0.0)) - assert_angles_almost_equal(ae, [-1.0, 0.0], decimal=12) - ae = np.array(self.plane_to_sphere(0.0, 0.0, 0.0, 1.0)) - assert_angles_almost_equal(ae, [0.0, 1.0], decimal=12) - ae = np.array(self.plane_to_sphere(0.0, 0.0, 0.0, -1.0)) - assert_angles_almost_equal(ae, [0.0, -1.0], decimal=12) - # Points on circle with radius pi in plane - ae = np.array(self.plane_to_sphere(0.0, 0.0, np.pi, 0.0)) - assert_angles_almost_equal(ae, [np.pi, 0.0], decimal=12) - ae = np.array(self.plane_to_sphere(0.0, 0.0, -np.pi, 0.0)) - assert_angles_almost_equal(ae, [-np.pi, 0.0], decimal=12) - ae = np.array(self.plane_to_sphere(0.0, 0.0, 0.0, np.pi)) - assert_angles_almost_equal(ae, [np.pi, 0.0], decimal=12) - ae = np.array(self.plane_to_sphere(0.0, 0.0, 0.0, -np.pi)) - assert_angles_almost_equal(ae, [np.pi, 0.0], decimal=12) - # Reference point at pole on sphere - ae = np.array(self.plane_to_sphere(0.0, -np.pi / 2.0, np.pi / 2.0, 0.0)) - assert_angles_almost_equal(ae, [np.pi / 2.0, 0.0], decimal=12) - ae = np.array(self.plane_to_sphere(0.0, -np.pi / 2.0, -np.pi / 2.0, 0.0)) - assert_angles_almost_equal(ae, [-np.pi / 2.0, 0.0], decimal=12) - ae = np.array(self.plane_to_sphere(0.0, -np.pi / 2.0, 0.0, np.pi / 2.0)) - assert_angles_almost_equal(ae, [0.0, 0.0], decimal=12) - ae = np.array(self.plane_to_sphere(0.0, -np.pi / 2.0, 0.0, -np.pi / 2.0)) - assert_angles_almost_equal(ae, [np.pi, 0.0], decimal=12) - # Points outside allowed domain in plane - with pytest.raises(ValueError): - self.plane_to_sphere(0.0, 0.0, 4.0, 0.0) - with pytest.raises(ValueError): - self.plane_to_sphere(0.0, 0.0, 0.0, 4.0) + sphere_to_plane(*sphere) + else: + xy = np.array(sphere_to_plane(*sphere)) + np.testing.assert_almost_equal(xy, plane, decimal=12) -class TestProjectionSTG: - """Test stereographic projection.""" +def test_sphere_to_plane_special(): + """Test special corner cases (sphere -> plane).""" + sphere_to_plane = katpoint.sphere_to_plane['ARC'] + # Point diametrically opposite the reference point on sphere + xy = np.array(sphere_to_plane(PI, 0.0, 0.0, 0.0)) + np.testing.assert_almost_equal(np.abs(xy), [PI, 0.0], decimal=12) - def setup(self): - self.plane_to_sphere = katpoint.plane_to_sphere['STG'] - self.sphere_to_plane = katpoint.sphere_to_plane['STG'] - N = 100 - # Stay well away from point of projection - max_theta = 0.8 * np.pi - self.az0 = np.pi * (2.0 * np.random.rand(N) - 1.0) - # Keep away from poles (leave them as corner cases) - self.el0 = 0.999 * np.pi * (np.random.rand(N) - 0.5) - # Perform inverse STG mapping to spread out points on plane - theta = max_theta * np.random.rand(N) - r = 2.0 * np.sin(theta) / (1.0 + np.cos(theta)) - phi = 2 * np.pi * np.random.rand(N) - self.x = r * np.cos(phi) - self.y = r * np.sin(phi) - - def test_random_closure(self): - """STG projection: do random projections and check closure.""" - az, el = self.plane_to_sphere(self.az0, self.el0, self.x, self.y) - xx, yy = self.sphere_to_plane(self.az0, self.el0, az, el) - aa, ee = self.plane_to_sphere(self.az0, self.el0, xx, yy) - np.testing.assert_almost_equal(self.x, xx, decimal=9) - np.testing.assert_almost_equal(self.y, yy, decimal=9) - assert_angles_almost_equal(az, aa, decimal=9) - assert_angles_almost_equal(el, ee, decimal=9) - - @require_aips - def test_aips_compatibility(self): - """STG projection: compare with original AIPS routine.""" - az, el = self.plane_to_sphere(self.az0, self.el0, self.x, self.y) - xx, yy = self.sphere_to_plane(self.az0, self.el0, az, el) - az_aips, el_aips = np.zeros(az.shape), np.zeros(el.shape) - x_aips, y_aips = np.zeros(xx.shape), np.zeros(yy.shape) - for n in range(len(az)): - az_aips[n], el_aips[n], ierr = newpos( - 6, self.az0[n], self.el0[n], self.x[n], self.y[n]) - x_aips[n], y_aips[n], ierr = dircos( - 6, self.az0[n], self.el0[n], az[n], el[n]) - assert ierr == 0 - # AIPS NEWPOS STG has poor accuracy on azimuth angle (large closure errors by itself) - # assert_angles_almost_equal(az, az_aips, decimal=9) - assert_angles_almost_equal(el, el_aips, decimal=9) - np.testing.assert_almost_equal(xx, x_aips, decimal=9) - np.testing.assert_almost_equal(yy, y_aips, decimal=9) - - def test_corner_cases(self): - """STG projection: test special corner cases.""" - # SPHERE TO PLANE - # Origin - xy = np.array(self.sphere_to_plane(0.0, 0.0, 0.0, 0.0)) - np.testing.assert_almost_equal(xy, [0.0, 0.0], decimal=12) - # Points 90 degrees from reference point on sphere - xy = np.array(self.sphere_to_plane(0.0, 0.0, np.pi / 2.0, 0.0)) - np.testing.assert_almost_equal(xy, [2.0, 0.0], decimal=12) - xy = np.array(self.sphere_to_plane(0.0, 0.0, -np.pi / 2.0, 0.0)) - np.testing.assert_almost_equal(xy, [-2.0, 0.0], decimal=12) - xy = np.array(self.sphere_to_plane(0.0, 0.0, 0.0, np.pi / 2.0)) - np.testing.assert_almost_equal(xy, [0.0, 2.0], decimal=12) - xy = np.array(self.sphere_to_plane(0.0, 0.0, 0.0, -np.pi / 2.0)) - np.testing.assert_almost_equal(xy, [0.0, -2.0], decimal=12) - # Reference point at pole on sphere - xy = np.array(self.sphere_to_plane(0.0, np.pi / 2.0, 0.0, 0.0)) - np.testing.assert_almost_equal(xy, [0.0, -2.0], decimal=12) - xy = np.array(self.sphere_to_plane(0.0, np.pi / 2.0, np.pi, 0.0)) - np.testing.assert_almost_equal(xy, [0.0, 2.0], decimal=12) - xy = np.array(self.sphere_to_plane(0.0, np.pi / 2.0, np.pi / 2.0, 0.0)) - np.testing.assert_almost_equal(xy, [2.0, 0.0], decimal=12) - xy = np.array(self.sphere_to_plane(0.0, np.pi / 2.0, -np.pi / 2.0, 0.0)) - np.testing.assert_almost_equal(xy, [-2.0, 0.0], decimal=12) - # Points outside allowed domain on sphere - with pytest.raises(ValueError): - self.sphere_to_plane(0.0, 0.0, np.pi, 0.0) - with pytest.raises(ValueError): - self.sphere_to_plane(0.0, 0.0, 0.0, np.pi) - # PLANE TO SPHERE +@pytest.mark.parametrize( + "projection, plane, sphere", + [ # Origin - ae = np.array(self.plane_to_sphere(0.0, 0.0, 0.0, 0.0)) - assert_angles_almost_equal(ae, [0.0, 0.0], decimal=12) + ('SIN', (0.0, 0.0, 0.0, 0.0), [0.0, 0.0]), + ('TAN', (0.0, 0.0, 0.0, 0.0), [0.0, 0.0]), + ('ARC', (0.0, 0.0, 0.0, 0.0), [0.0, 0.0]), + ('STG', (0.0, 0.0, 0.0, 0.0), [0.0, 0.0]), + ('SSN', (0.0, 0.0, 0.0, 0.0), [0.0, 0.0]), + # Points on unit circle in plane + ('SIN', (0.0, 0.0, 1.0, 0.0), [PI/2, 0.0]), + ('SIN', (0.0, 0.0, -1.0, 0.0), [-PI/2, 0.0]), + ('SIN', (0.0, 0.0, 0.0, 1.0), [0.0, PI/2]), + ('SIN', (0.0, 0.0, 0.0, -1.0), [0.0, -PI/2]), + ('TAN', (0.0, 0.0, 1.0, 0.0), [PI/4, 0.0]), + ('TAN', (0.0, 0.0, -1.0, 0.0), [-PI/4, 0.0]), + ('TAN', (0.0, 0.0, 0.0, 1.0), [0.0, PI/4]), + ('TAN', (0.0, 0.0, 0.0, -1.0), [0.0, -PI/4]), + ('ARC', (0.0, 0.0, 1.0, 0.0), [1.0, 0.0]), + ('ARC', (0.0, 0.0, -1.0, 0.0), [-1.0, 0.0]), + ('ARC', (0.0, 0.0, 0.0, 1.0), [0.0, 1.0]), + ('ARC', (0.0, 0.0, 0.0, -1.0), [0.0, -1.0]), + ('SSN', (0.0, 0.0, 1.0, 0.0), [-PI/2, 0.0]), + ('SSN', (0.0, 0.0, -1.0, 0.0), [PI/2, 0.0]), + ('SSN', (0.0, 0.0, 0.0, 1.0), [0.0, -PI/2]), + ('SSN', (0.0, 0.0, 0.0, -1.0), [0.0, PI/2]), # Points on circle of radius 2.0 in plane - ae = np.array(self.plane_to_sphere(0.0, 0.0, 2.0, 0.0)) - assert_angles_almost_equal(ae, [np.pi / 2.0, 0.0], decimal=12) - ae = np.array(self.plane_to_sphere(0.0, 0.0, -2.0, 0.0)) - assert_angles_almost_equal(ae, [-np.pi / 2.0, 0.0], decimal=12) - ae = np.array(self.plane_to_sphere(0.0, 0.0, 0.0, 2.0)) - assert_angles_almost_equal(ae, [0.0, np.pi / 2.0], decimal=12) - ae = np.array(self.plane_to_sphere(0.0, 0.0, 0.0, -2.0)) - assert_angles_almost_equal(ae, [0.0, -np.pi / 2.0], decimal=12) + ('STG', (0.0, 0.0, 2.0, 0.0), [PI/2, 0.0]), + ('STG', (0.0, 0.0, -2.0, 0.0), [-PI/2, 0.0]), + ('STG', (0.0, 0.0, 0.0, 2.0), [0.0, PI/2]), + ('STG', (0.0, 0.0, 0.0, -2.0), [0.0, -PI/2]), + # Points on circle with radius pi in plane + ('ARC', (0.0, 0.0, PI, 0.0), [PI, 0.0]), + ('ARC', (0.0, 0.0, -PI, 0.0), [-PI, 0.0]), + ('ARC', (0.0, 0.0, 0.0, PI), [PI, 0.0]), + ('ARC', (0.0, 0.0, 0.0, -PI), [PI, 0.0]), # Reference point at pole on sphere - ae = np.array(self.plane_to_sphere(0.0, -np.pi / 2.0, 2.0, 0.0)) - assert_angles_almost_equal(ae, [np.pi / 2.0, 0.0], decimal=12) - ae = np.array(self.plane_to_sphere(0.0, -np.pi / 2.0, -2.0, 0.0)) - assert_angles_almost_equal(ae, [-np.pi / 2.0, 0.0], decimal=12) - ae = np.array(self.plane_to_sphere(0.0, -np.pi / 2.0, 0.0, 2.0)) - assert_angles_almost_equal(ae, [0.0, 0.0], decimal=12) - ae = np.array(self.plane_to_sphere(0.0, -np.pi / 2.0, 0.0, -2.0)) - assert_angles_almost_equal(ae, [np.pi, 0.0], decimal=12) - - -class TestProjectionCAR: - """Test plate carree projection.""" - - def setup(self): - self.plane_to_sphere = katpoint.plane_to_sphere['CAR'] - self.sphere_to_plane = katpoint.sphere_to_plane['CAR'] - N = 100 - # Unrestricted (az0, el0) points on sphere - self.az0 = np.pi * (2.0 * np.random.rand(N) - 1.0) - self.el0 = np.pi * (np.random.rand(N) - 0.5) - # Unrestricted (x, y) points on corresponding plane - self.x = np.pi * (2.0 * np.random.rand(N) - 1.0) - self.y = np.pi * (np.random.rand(N) - 0.5) - - def test_random_closure(self): - """CAR projection: do random projections and check closure.""" - az, el = self.plane_to_sphere(self.az0, self.el0, self.x, self.y) - xx, yy = self.sphere_to_plane(self.az0, self.el0, az, el) - aa, ee = self.plane_to_sphere(self.az0, self.el0, xx, yy) - np.testing.assert_almost_equal(self.x, xx, decimal=12) - np.testing.assert_almost_equal(self.y, yy, decimal=12) - assert_angles_almost_equal(az, aa, decimal=12) - assert_angles_almost_equal(el, ee, decimal=12) + ('SIN', (0.0, -PI/2, 1.0, 0.0), [PI/2, 0.0]), + ('SIN', (0.0, -PI/2, -1.0, 0.0), [-PI/2, 0.0]), + ('SIN', (0.0, -PI/2, 0.0, 1.0), [0.0, 0.0]), + ('SIN', (0.0, -PI/2, 0.0, -1.0), [PI, 0.0]), + ('TAN', (0.0, -PI/2, 1.0, 0.0), [PI/2, -PI/4]), + ('TAN', (0.0, -PI/2, -1.0, 0.0), [-PI/2, -PI/4]), + ('TAN', (0.0, -PI/2, 0.0, 1.0), [0.0, -PI/4]), + ('TAN', (0.0, -PI/2, 0.0, -1.0), [PI, -PI/4]), + ('ARC', (0.0, -PI/2, PI/2, 0.0), [PI/2, 0.0]), + ('ARC', (0.0, -PI/2, -PI/2, 0.0), [-PI/2, 0.0]), + ('ARC', (0.0, -PI/2, 0.0, PI/2), [0.0, 0.0]), + ('ARC', (0.0, -PI/2, 0.0, -PI/2), [PI, 0.0]), + ('STG', (0.0, -PI/2, 2.0, 0.0), [PI/2, 0.0]), + ('STG', (0.0, -PI/2, -2.0, 0.0), [-PI/2, 0.0]), + ('STG', (0.0, -PI/2, 0.0, 2.0), [0.0, 0.0]), + ('STG', (0.0, -PI/2, 0.0, -2.0), [PI, 0.0]), + ('SSN', (0.0, PI/2, 0.0, 1.0), [0.0, 0.0]), + ('SSN', (0.0, -PI/2, 0.0, -1.0), [0.0, 0.0]), + # Test valid (x, y) domain + ('SSN', (0.0, 1.0, 0.0, -np.cos(1.0)), [0.0, PI/2]), + ('SSN', (0.0, -1.0, 0.0, np.cos(1.0)), [0.0, -PI/2]), + # Points outside allowed domain in plane + ('SIN', (0.0, 0.0, 2.0, 0.0), None), + ('SIN', (0.0, 0.0, 0.0, 2.0), None), + ('ARC', (0.0, 0.0, 4.0, 0.0), None), + ('ARC', (0.0, 0.0, 0.0, 4.0), None), + ('SSN', (0.0, 0.0, 2.0, 0.0), None), + ('SSN', (0.0, 0.0, 0.0, 2.0), None), + ] +) +def test_plane_to_sphere(projection, plane, sphere): + """Test specific cases (plane -> sphere).""" + plane_to_sphere = katpoint.plane_to_sphere[projection] + if sphere is None: + with pytest.raises(ValueError): + plane_to_sphere(*plane) + else: + ae = np.array(plane_to_sphere(*plane)) + assert_angles_almost_equal(ae, sphere, decimal=12) def sphere_to_plane_original_ssn(target_az, target_el, scan_az, scan_el): @@ -498,101 +337,14 @@ def plane_to_sphere_original_ssn(target_az, target_el, ll, mm): return scan_az, scan_el -class TestProjectionSSN: - """Test swapped orthographic projection.""" - - def setup(self): - self.plane_to_sphere = katpoint.plane_to_sphere['SSN'] - self.sphere_to_plane = katpoint.sphere_to_plane['SSN'] - N = 100 - self.az0 = np.pi * (2.0 * np.random.rand(N) - 1.0) - # Keep away from poles (leave them as corner cases) - self.el0 = 0.999 * np.pi * (np.random.rand(N) - 0.5) - # (x, y) points within complicated SSN domain - clipped unit circle - cos_el0 = np.cos(self.el0) - # The x coordinate is bounded by +- cos(el0) - self.x = (2 * np.random.rand(N) - 1) * cos_el0 - # The y coordinate ranges between two (semi-)circles centred on origin: - # the unit circle on one side and circle of radius cos(el0) on other side - y_offset = -np.sqrt(cos_el0 ** 2 - self.x ** 2) - y_range = -y_offset + np.sqrt(1.0 - self.x ** 2) - self.y = (y_range * np.random.rand(N) + y_offset) * np.sign(self.el0) - - def test_random_closure(self): - """SSN projection: do random projections and check closure.""" - az, el = self.plane_to_sphere(self.az0, self.el0, self.x, self.y) - xx, yy = self.sphere_to_plane(self.az0, self.el0, az, el) - aa, ee = self.plane_to_sphere(self.az0, self.el0, xx, yy) - np.testing.assert_almost_equal(self.x, xx, decimal=10) - np.testing.assert_almost_equal(self.y, yy, decimal=10) - assert_angles_almost_equal(az, aa, decimal=10) - assert_angles_almost_equal(el, ee, decimal=10) - - def test_vs_original_ssn(self): - """SSN projection: compare against Mattieu's original version.""" - az, el = self.plane_to_sphere(self.az0, self.el0, self.x, self.y) - ll, mm = sphere_to_plane_original_ssn(self.az0, self.el0, az, el) - aa, ee = plane_to_sphere_original_ssn(self.az0, self.el0, ll, mm) - np.testing.assert_almost_equal(self.x, ll, decimal=10) - np.testing.assert_almost_equal(self.y, -mm, decimal=10) - assert_angles_almost_equal(az, aa, decimal=10) - assert_angles_almost_equal(el, ee, decimal=10) - - def test_corner_cases(self): - """SSN projection: test special corner cases.""" - # SPHERE TO PLANE - # Origin - xy = np.array(self.sphere_to_plane(0.0, 0.0, 0.0, 0.0)) - np.testing.assert_almost_equal(xy, [0.0, 0.0], decimal=12) - # Points 90 degrees from reference point on sphere - xy = np.array(self.sphere_to_plane(0.0, 0.0, np.pi / 2.0, 0.0)) - np.testing.assert_almost_equal(xy, [-1.0, 0.0], decimal=12) - xy = np.array(self.sphere_to_plane(0.0, 0.0, -np.pi / 2.0, 0.0)) - np.testing.assert_almost_equal(xy, [1.0, 0.0], decimal=12) - xy = np.array(self.sphere_to_plane(0.0, 0.0, 0.0, np.pi / 2.0)) - np.testing.assert_almost_equal(xy, [0.0, -1.0], decimal=12) - xy = np.array(self.sphere_to_plane(0.0, 0.0, 0.0, -np.pi / 2.0)) - np.testing.assert_almost_equal(xy, [0.0, 1.0], decimal=12) - # Reference point at pole on sphere - xy = np.array(self.sphere_to_plane(0.0, np.pi / 2.0, 0.0, 0.0)) - np.testing.assert_almost_equal(xy, [0.0, 1.0], decimal=12) - xy = np.array(self.sphere_to_plane(0.0, np.pi / 2.0, np.pi, 1e-8)) - np.testing.assert_almost_equal(xy, [0.0, 1.0], decimal=12) - xy = np.array(self.sphere_to_plane(0.0, np.pi / 2.0, np.pi / 2.0, 0.0)) - np.testing.assert_almost_equal(xy, [0.0, 1.0], decimal=12) - xy = np.array(self.sphere_to_plane(0.0, np.pi / 2.0, -np.pi / 2.0, 0.0)) - np.testing.assert_almost_equal(xy, [0.0, 1.0], decimal=12) - # Points outside allowed domain on sphere - with pytest.raises(ValueError): - self.sphere_to_plane(0.0, 0.0, np.pi, 0.0) - with pytest.raises(ValueError): - self.sphere_to_plane(0.0, 0.0, 0.0, np.pi) - - # PLANE TO SPHERE - # Origin - ae = np.array(self.plane_to_sphere(0.0, 0.0, 0.0, 0.0)) - assert_angles_almost_equal(ae, [0.0, 0.0], decimal=12) - # Points on unit circle in plane - ae = np.array(self.plane_to_sphere(0.0, 0.0, 1.0, 0.0)) - assert_angles_almost_equal(ae, [-np.pi / 2.0, 0.0], decimal=12) - ae = np.array(self.plane_to_sphere(0.0, 0.0, -1.0, 0.0)) - assert_angles_almost_equal(ae, [np.pi / 2.0, 0.0], decimal=12) - ae = np.array(self.plane_to_sphere(0.0, 0.0, 0.0, 1.0)) - assert_angles_almost_equal(ae, [0.0, -np.pi / 2.0], decimal=12) - ae = np.array(self.plane_to_sphere(0.0, 0.0, 0.0, -1.0)) - assert_angles_almost_equal(ae, [0.0, np.pi / 2.0], decimal=12) - # Reference point at pole on sphere - ae = np.array(self.plane_to_sphere(0.0, np.pi / 2.0, 0.0, 1.0)) - assert_angles_almost_equal(ae, [0.0, 0.0], decimal=12) - ae = np.array(self.plane_to_sphere(0.0, -np.pi / 2.0, 0.0, -1.0)) - assert_angles_almost_equal(ae, [0.0, 0.0], decimal=12) - # Test valid (x, y) domain - ae = np.array(self.plane_to_sphere(0.0, 1.0, 0.0, -np.cos(1.0))) - assert_angles_almost_equal(ae, [0.0, np.pi / 2.0], decimal=12) - ae = np.array(self.plane_to_sphere(0.0, -1.0, 0.0, np.cos(1.0))) - assert_angles_almost_equal(ae, [0.0, -np.pi / 2.0], decimal=12) - # Points outside allowed domain in plane - with pytest.raises(ValueError): - self.plane_to_sphere(0.0, 0.0, 2.0, 0.0) - with pytest.raises(ValueError): - self.plane_to_sphere(0.0, 0.0, 0.0, 2.0) +def test_vs_original_ssn(decimal=10, N=100): + """SSN projection: compare against Mattieu's original version.""" + plane_to_sphere = katpoint.plane_to_sphere['SSN'] + az0, el0, x, y = generate_data['SSN'](N) + az, el = plane_to_sphere(az0, el0, x, y) + ll, mm = sphere_to_plane_original_ssn(az0, el0, az, el) + aa, ee = plane_to_sphere_original_ssn(az0, el0, ll, mm) + np.testing.assert_almost_equal(x, ll, decimal=decimal) + np.testing.assert_almost_equal(y, -mm, decimal=decimal) + assert_angles_almost_equal(az, aa, decimal=decimal) + assert_angles_almost_equal(el, ee, decimal=decimal) diff --git a/katpoint/test/test_target.py b/katpoint/test/test_target.py index 8b75e83..22e7642 100644 --- a/katpoint/test/test_target.py +++ b/katpoint/test/test_target.py @@ -26,7 +26,7 @@ import katpoint -# Use the current year in TLE epochs to avoid pyephem crash due to expired TLEs +# Use the current year in TLE epochs to avoid potential crashes due to expired TLEs YY = time.localtime().tm_year % 100 @@ -34,50 +34,6 @@ class TestTargetConstruction: """Test construction of targets from strings and vice versa.""" def setup(self): - self.valid_targets = ['azel, -30.0, 90.0', - ', azel, 180, -45:00:00.0', - 'Zenith, azel, 0, 90', - 'radec J2000, 0, 0.0, (1000.0 2000.0 1.0 10.0)', - ', radec B1950, 14:23:45.6, -60:34:21.1', - 'radec B1900, 14:23:45.6, -60:34:21.1', - 'gal, 300.0, 0.0', - 'Sag A, gal, 0.0, 0.0', - 'Zizou, radec cal, 1.4, 30.0, (1000.0 2000.0 1.0 10.0)', - 'Fluffy | *Dinky, radec, 12.5, -50.0, (1.0 2.0 1.0 2.0 3.0 4.0)', - 'tle, GPS BIIA-21 (PRN 09) \n' + - ('1 22700U 93042A %02d266.32333151 .00000012 00000-0 10000-3 0 805%1d\n' % - (YY, (YY // 10 + YY - 7 + 4) % 10)) + - '2 22700 55.4408 61.3790 0191986 78.1802 283.9935 2.00561720104282\n', - ', tle, GPS BIIA-22 (PRN 05) \n' + - ('1 22779U 93054A %02d266.92814765 .00000062 00000-0 10000-3 0 289%1d\n' % - (YY, (YY // 10 + YY - 7 + 5) % 10)) + - '2 22779 53.8943 118.4708 0081407 68.2645 292.7207 2.00558015103055\n', - 'Sun, special', - 'Nothing, special', - 'Moon | Luna, special solarbody', - 'Aldebaran, star', - 'Betelgeuse | Maitland, star orion', - 'xephem star, Sadr~f|S|F8~20:22:13.7|2.43~40:15:24|-0.93~2.23~2000~0', - 'Acamar | Theta Eridani, xephem, HIC 13847~f|S|A4~2:58:16.03~-40:18:17.1~2.906~2000~0', - 'Kakkab | A Lupi, xephem, H71860 | S225128~f|S|B1~14:41:55.768~-47:23:17.51~2.304~2000~0'] - self.invalid_targets = ['Sun', - 'Sun, ', - '-30.0, 90.0', - ', azel, -45:00:00.0', - 'Zenith, azel blah', - 'radec J2000, 0.3', - 'gal, 0.0', - 'Zizou, radec cal, 1.4, 30.0, (1000.0, 2000.0, 1.0, 10.0)', - 'tle, GPS BIIA-21 (PRN 09) \n' + - '2 22700 55.4408 61.3790 0191986 78.1802 283.9935 2.00561720104282\n', - ', tle, GPS BIIA-22 (PRN 05) \n' + - ('1 93054A %02d266.92814765 .00000062 00000-0 10000-3 0 289%1d\n' % - (YY, (YY // 10 + YY - 7 + 5) % 10)) + - '2 22779 53.8943 118.4708 0081407 68.2645 292.7207 2.00558015103055\n', - 'Sunny, special', - 'Slinky, star', - 'xephem star, Sadr~20:22:13.7|2.43~40:15:24|-0.93~2.23~2000~0', - 'hotbody, 34.0, 45.0'] self.azel_target = 'azel, 10.0, -10.0' # A floating-point RA is in degrees self.radec_target = 'radec, 20.0, -20.0' @@ -88,17 +44,6 @@ def setup(self): def test_construct_target(self): """Test construction of targets from strings and vice versa.""" - valid_targets = [katpoint.Target(descr) for descr in self.valid_targets] - valid_strings = [t.description for t in valid_targets] - for descr in valid_strings: - t = katpoint.Target(descr) - assert descr == t.description, ( - "Target description ('%s') differs from original string ('%s')" - % (t.description, descr)) - print('%r %s' % (t, t)) - for descr in self.invalid_targets: - with pytest.raises(ValueError): - katpoint.Target(descr) azel1 = katpoint.Target(self.azel_target) azel2 = katpoint.construct_azel_target('10:00:00.0', '-10:00:00.0') assert azel1 == azel2, 'Special azel constructor failed' @@ -167,6 +112,78 @@ def test_add_tags(self): 'Added tags not correct') +@pytest.mark.parametrize( + "description", + [ + 'azel, -30.0, 90.0', + ', azel, 180, -45:00:00.0', + 'Zenith, azel, 0, 90', + 'radec J2000, 0, 0.0, (1000.0 2000.0 1.0 10.0)', + ', radec B1950, 14:23:45.6, -60:34:21.1', + 'radec B1900, 14:23:45.6, -60:34:21.1', + 'gal, 300.0, 0.0', + 'Sag A, gal, 0.0, 0.0', + 'Zizou, radec cal, 1.4, 30.0, (1000.0 2000.0 1.0 10.0)', + 'Fluffy | *Dinky, radec, 12.5, -50.0, (1.0 2.0 1.0 2.0 3.0 4.0)', + ('tle, GPS BIIA-21 (PRN 09) \n' + '1 22700U 93042A {:02d}266.32333151 .00000012 00000-0 10000-3 0 805{:1d}\n' + '2 22700 55.4408 61.3790 0191986 78.1802 283.9935 2.00561720104282\n' + .format(YY, (YY // 10 + YY - 7 + 4) % 10)), + (', tle, GPS BIIA-22 (PRN 05) \n' + '1 22779U 93054A {:02d}266.92814765 .00000062 00000-0 10000-3 0 289{:1d}\n' + '2 22779 53.8943 118.4708 0081407 68.2645 292.7207 2.00558015103055\n' + .format(YY, (YY // 10 + YY - 7 + 5) % 10)), + 'Sun, special', + 'Nothing, special', + 'Moon | Luna, special solarbody', + 'Aldebaran, star', + 'Betelgeuse | Maitland, star orion', + 'xephem star, Sadr~f|S|F8~20:22:13.7|2.43~40:15:24|-0.93~2.23~2000~0', + 'Acamar | Theta Eridani, xephem, HIC 13847~f|S|A4~2:58:16.03~-40:18:17.1~2.906~2000~0', + 'Kakkab, xephem, H71860 | S225128~f|S|B1~14:41:55.768~-47:23:17.51~2.304~2000~0', + ] +) +def test_construct_valid_target(description): + """Test construction of valid targets from strings and vice versa.""" + # Normalise description string through one cycle to allow comparison + reference_description = katpoint.Target(description).description + test_target = katpoint.Target(reference_description) + assert test_target.description == reference_description, ( + "Target description ('{}') differs from reference ('{}')" + .format(test_target.description, reference_description)) + # Exercise repr() and str() + print('{!r} {}'.format(test_target, test_target)) + + +@pytest.mark.parametrize( + "description", + [ + 'Sun', + 'Sun, ', + '-30.0, 90.0', + ', azel, -45:00:00.0', + 'Zenith, azel blah', + 'radec J2000, 0.3', + 'gal, 0.0', + 'Zizou, radec cal, 1.4, 30.0, (1000.0, 2000.0, 1.0, 10.0)', + ('tle, GPS BIIA-21 (PRN 09) \n' + '2 22700 55.4408 61.3790 0191986 78.1802 283.9935 2.00561720104282\n'), + (', tle, GPS BIIA-22 (PRN 05) \n' + '1 93054A {:02d}266.92814765 .00000062 00000-0 10000-3 0 289{:1d}\n' + '2 22779 53.8943 118.4708 0081407 68.2645 292.7207 2.00558015103055\n' + .format(YY, (YY // 10 + YY - 7 + 5) % 10)), + 'Sunny, special', + 'Slinky, star', + 'xephem star, Sadr~20:22:13.7|2.43~40:15:24|-0.93~2.23~2000~0', + 'hotbody, 34.0, 45.0', + ] +) +def test_construct_invalid_target(description): + """Test construction of invalid targets from strings.""" + with pytest.raises(ValueError): + katpoint.Target(description) + + class TestTargetCalculations: """Test various calculations involving antennas and timestamps.""" From 4a687f5301ae909e1e583ca7cac616d5fe6a500f Mon Sep 17 00:00:00 2001 From: Ludwig Schwardt Date: Mon, 13 Jul 2020 16:32:30 +0200 Subject: [PATCH 024/122] Reduce duplication in test parametrizations A lot of the projections have similar test patterns when calling the test_plane_to_sphere / test_sphere_to_plane functions. Group these in higher-level test functions, leaving the idiosyncratic ones with the original functions. --- katpoint/test/test_projection.py | 152 ++++++++++++++----------------- 1 file changed, 69 insertions(+), 83 deletions(-) diff --git a/katpoint/test/test_projection.py b/katpoint/test/test_projection.py index f6bbda4..aeb7a88 100644 --- a/katpoint/test/test_projection.py +++ b/katpoint/test/test_projection.py @@ -112,6 +112,8 @@ def generate_data_ssn(N): 'CAR': generate_data_car, 'SSN': generate_data_ssn} +# The decimal accuracy for each projection is the maximum that makes +# the test pass during an extended random run. @pytest.mark.parametrize('projection, decimal', [('SIN', 10), ('TAN', 8), ('ARC', 8), ('STG', 9), ('CAR', 12), ('SSN', 10)]) @@ -129,6 +131,8 @@ def test_random_closure(projection, decimal, N=100): assert_angles_almost_equal(el, ee, decimal=decimal) +# The decimal accuracy for each projection is the maximum that makes +# the test pass during an extended random run. @pytest.mark.skipif(not HAS_AIPS, reason="AIPS projection module not found") @pytest.mark.parametrize('projection, aips_code, decimal', [('SIN', 2, 9), ('TAN', 3, 10), ('ARC', 4, 8), ('STG', 6, 9)]) @@ -162,34 +166,6 @@ def test_aips_compatibility(projection, aips_code, decimal, N=100): @pytest.mark.parametrize( "projection, sphere, plane", [ - # Origin - ('SIN', (0.0, 0.0, 0.0, 0.0), [0.0, 0.0]), - ('TAN', (0.0, 0.0, 0.0, 0.0), [0.0, 0.0]), - ('ARC', (0.0, 0.0, 0.0, 0.0), [0.0, 0.0]), - ('STG', (0.0, 0.0, 0.0, 0.0), [0.0, 0.0]), - ('SSN', (0.0, 0.0, 0.0, 0.0), [0.0, 0.0]), - # Points 45 degrees from reference point on sphere - ('TAN', (0.0, 0.0, PI/4, 0.0), [1.0, 0.0]), - ('TAN', (0.0, 0.0, -PI/4, 0.0), [-1.0, 0.0]), - ('TAN', (0.0, 0.0, 0.0, PI/4), [0.0, 1.0]), - ('TAN', (0.0, 0.0, 0.0, -PI/4), [0.0, -1.0]), - # Points 90 degrees from reference point on sphere - ('SIN', (0.0, 0.0, PI/2, 0.0), [1.0, 0.0]), - ('SIN', (0.0, 0.0, -PI/2, 0.0), [-1.0, 0.0]), - ('SIN', (0.0, 0.0, 0.0, PI/2), [0.0, 1.0]), - ('SIN', (0.0, 0.0, 0.0, -PI/2), [0.0, -1.0]), - ('ARC', (0.0, 0.0, PI/2, 0.0), [PI/2, 0.0]), - ('ARC', (0.0, 0.0, -PI/2, 0.0), [-PI/2, 0.0]), - ('ARC', (0.0, 0.0, 0.0, PI/2), [0.0, PI/2]), - ('ARC', (0.0, 0.0, 0.0, -PI/2), [0.0, -PI/2]), - ('STG', (0.0, 0.0, PI/2, 0.0), [2.0, 0.0]), - ('STG', (0.0, 0.0, -PI/2, 0.0), [-2.0, 0.0]), - ('STG', (0.0, 0.0, 0.0, PI/2), [0.0, 2.0]), - ('STG', (0.0, 0.0, 0.0, -PI/2), [0.0, -2.0]), - ('SSN', (0.0, 0.0, PI/2, 0.0), [-1.0, 0.0]), - ('SSN', (0.0, 0.0, -PI/2, 0.0), [1.0, 0.0]), - ('SSN', (0.0, 0.0, 0.0, PI/2), [0.0, -1.0]), - ('SSN', (0.0, 0.0, 0.0, -PI/2), [0.0, 1.0]), # Reference point at pole on sphere ('SIN', (0.0, PI/2, 0.0, 0.0), [0.0, -1.0]), ('SIN', (0.0, PI/2, PI, 1e-8), [0.0, 1.0]), @@ -212,15 +188,7 @@ def test_aips_compatibility(projection, aips_code, decimal, N=100): ('SSN', (0.0, PI/2, PI/2, 0.0), [0.0, 1.0]), ('SSN', (0.0, PI/2, -PI/2, 0.0), [0.0, 1.0]), # Points outside allowed domain on sphere - ('SIN', (0.0, 0.0, PI, 0.0), None), - ('SIN', (0.0, 0.0, 0.0, PI), None), - ('TAN', (0.0, 0.0, PI, 0.0), None), - ('TAN', (0.0, 0.0, 0.0, PI), None), ('ARC', (0.0, 0.0, 0.0, PI), None), - ('STG', (0.0, 0.0, PI, 0.0), None), - ('STG', (0.0, 0.0, 0.0, PI), None), - ('SSN', (0.0, 0.0, PI, 0.0), None), - ('SSN', (0.0, 0.0, 0.0, PI), None), ] ) def test_sphere_to_plane(projection, sphere, plane): @@ -234,6 +202,33 @@ def test_sphere_to_plane(projection, sphere, plane): np.testing.assert_almost_equal(xy, plane, decimal=12) +@pytest.mark.parametrize("projection", ['SIN', 'TAN', 'ARC', 'STG', 'SSN']) +def test_sphere_to_plane_origin(projection): + """Test origin (sphere -> plane).""" + test_sphere_to_plane(projection, (0.0, 0.0, 0.0, 0.0), [0.0, 0.0]) + + +@pytest.mark.parametrize("projection, offset_s, offset_p", + # Points 45 degrees from reference point on sphere + [('TAN', PI/4, 1.0), + # Points 90 degrees from reference point on sphere + ('SIN', PI/2, 1.0), ('ARC', PI/2, PI/2), + ('STG', PI/2, 2.0), ('SSN', PI/2, -1.0)]) +def test_sphere_to_plane_cross(projection, offset_s, offset_p): + """Test four-point cross along axes, centred on origin (sphere -> plane).""" + test_sphere_to_plane(projection, (0.0, 0.0, +offset_s, 0.0), [+offset_p, 0.0]) + test_sphere_to_plane(projection, (0.0, 0.0, -offset_s, 0.0), [-offset_p, 0.0]) + test_sphere_to_plane(projection, (0.0, 0.0, 0.0, +offset_s), [0.0, +offset_p]) + test_sphere_to_plane(projection, (0.0, 0.0, 0.0, -offset_s), [0.0, -offset_p]) + + +@pytest.mark.parametrize("projection", ['SIN', 'TAN', 'STG', 'SSN']) +def test_sphere_to_plane_outside_domain(projection): + """Test points outside allowed domain on sphere (sphere -> plane).""" + test_sphere_to_plane(projection, (0.0, 0.0, PI, 0.0), None) + test_sphere_to_plane(projection, (0.0, 0.0, 0.0, PI), None) + + def test_sphere_to_plane_special(): """Test special corner cases (sphere -> plane).""" sphere_to_plane = katpoint.sphere_to_plane['ARC'] @@ -245,68 +240,21 @@ def test_sphere_to_plane_special(): @pytest.mark.parametrize( "projection, plane, sphere", [ - # Origin - ('SIN', (0.0, 0.0, 0.0, 0.0), [0.0, 0.0]), - ('TAN', (0.0, 0.0, 0.0, 0.0), [0.0, 0.0]), - ('ARC', (0.0, 0.0, 0.0, 0.0), [0.0, 0.0]), - ('STG', (0.0, 0.0, 0.0, 0.0), [0.0, 0.0]), - ('SSN', (0.0, 0.0, 0.0, 0.0), [0.0, 0.0]), - # Points on unit circle in plane - ('SIN', (0.0, 0.0, 1.0, 0.0), [PI/2, 0.0]), - ('SIN', (0.0, 0.0, -1.0, 0.0), [-PI/2, 0.0]), - ('SIN', (0.0, 0.0, 0.0, 1.0), [0.0, PI/2]), - ('SIN', (0.0, 0.0, 0.0, -1.0), [0.0, -PI/2]), - ('TAN', (0.0, 0.0, 1.0, 0.0), [PI/4, 0.0]), - ('TAN', (0.0, 0.0, -1.0, 0.0), [-PI/4, 0.0]), - ('TAN', (0.0, 0.0, 0.0, 1.0), [0.0, PI/4]), - ('TAN', (0.0, 0.0, 0.0, -1.0), [0.0, -PI/4]), - ('ARC', (0.0, 0.0, 1.0, 0.0), [1.0, 0.0]), - ('ARC', (0.0, 0.0, -1.0, 0.0), [-1.0, 0.0]), - ('ARC', (0.0, 0.0, 0.0, 1.0), [0.0, 1.0]), - ('ARC', (0.0, 0.0, 0.0, -1.0), [0.0, -1.0]), - ('SSN', (0.0, 0.0, 1.0, 0.0), [-PI/2, 0.0]), - ('SSN', (0.0, 0.0, -1.0, 0.0), [PI/2, 0.0]), - ('SSN', (0.0, 0.0, 0.0, 1.0), [0.0, -PI/2]), - ('SSN', (0.0, 0.0, 0.0, -1.0), [0.0, PI/2]), - # Points on circle of radius 2.0 in plane - ('STG', (0.0, 0.0, 2.0, 0.0), [PI/2, 0.0]), - ('STG', (0.0, 0.0, -2.0, 0.0), [-PI/2, 0.0]), - ('STG', (0.0, 0.0, 0.0, 2.0), [0.0, PI/2]), - ('STG', (0.0, 0.0, 0.0, -2.0), [0.0, -PI/2]), # Points on circle with radius pi in plane ('ARC', (0.0, 0.0, PI, 0.0), [PI, 0.0]), ('ARC', (0.0, 0.0, -PI, 0.0), [-PI, 0.0]), ('ARC', (0.0, 0.0, 0.0, PI), [PI, 0.0]), ('ARC', (0.0, 0.0, 0.0, -PI), [PI, 0.0]), # Reference point at pole on sphere - ('SIN', (0.0, -PI/2, 1.0, 0.0), [PI/2, 0.0]), - ('SIN', (0.0, -PI/2, -1.0, 0.0), [-PI/2, 0.0]), - ('SIN', (0.0, -PI/2, 0.0, 1.0), [0.0, 0.0]), - ('SIN', (0.0, -PI/2, 0.0, -1.0), [PI, 0.0]), ('TAN', (0.0, -PI/2, 1.0, 0.0), [PI/2, -PI/4]), ('TAN', (0.0, -PI/2, -1.0, 0.0), [-PI/2, -PI/4]), ('TAN', (0.0, -PI/2, 0.0, 1.0), [0.0, -PI/4]), ('TAN', (0.0, -PI/2, 0.0, -1.0), [PI, -PI/4]), - ('ARC', (0.0, -PI/2, PI/2, 0.0), [PI/2, 0.0]), - ('ARC', (0.0, -PI/2, -PI/2, 0.0), [-PI/2, 0.0]), - ('ARC', (0.0, -PI/2, 0.0, PI/2), [0.0, 0.0]), - ('ARC', (0.0, -PI/2, 0.0, -PI/2), [PI, 0.0]), - ('STG', (0.0, -PI/2, 2.0, 0.0), [PI/2, 0.0]), - ('STG', (0.0, -PI/2, -2.0, 0.0), [-PI/2, 0.0]), - ('STG', (0.0, -PI/2, 0.0, 2.0), [0.0, 0.0]), - ('STG', (0.0, -PI/2, 0.0, -2.0), [PI, 0.0]), ('SSN', (0.0, PI/2, 0.0, 1.0), [0.0, 0.0]), ('SSN', (0.0, -PI/2, 0.0, -1.0), [0.0, 0.0]), # Test valid (x, y) domain ('SSN', (0.0, 1.0, 0.0, -np.cos(1.0)), [0.0, PI/2]), ('SSN', (0.0, -1.0, 0.0, np.cos(1.0)), [0.0, -PI/2]), - # Points outside allowed domain in plane - ('SIN', (0.0, 0.0, 2.0, 0.0), None), - ('SIN', (0.0, 0.0, 0.0, 2.0), None), - ('ARC', (0.0, 0.0, 4.0, 0.0), None), - ('ARC', (0.0, 0.0, 0.0, 4.0), None), - ('SSN', (0.0, 0.0, 2.0, 0.0), None), - ('SSN', (0.0, 0.0, 0.0, 2.0), None), ] ) def test_plane_to_sphere(projection, plane, sphere): @@ -320,6 +268,44 @@ def test_plane_to_sphere(projection, plane, sphere): assert_angles_almost_equal(ae, sphere, decimal=12) +@pytest.mark.parametrize("projection", ['SIN', 'TAN', 'ARC', 'STG', 'SSN']) +def test_plane_to_sphere_origin(projection): + """Test origin (plane -> sphere).""" + test_plane_to_sphere(projection, (0.0, 0.0, 0.0, 0.0), [0.0, 0.0]) + + +@pytest.mark.parametrize("projection, offset_p, offset_s", + # Points on unit circle in plane + [('SIN', 1.0, PI/2), ('TAN', 1.0, PI/4), + ('ARC', 1.0, 1.0), ('SSN', 1.0, -PI/2), + # Points on circle of radius 2.0 in plane + ('STG', 2.0, PI/2)]) +def test_plane_to_sphere_cross(projection, offset_p, offset_s): + """Test four-point cross along axes, centred on origin (plane -> sphere).""" + test_plane_to_sphere(projection, (0.0, 0.0, +offset_p, 0.0), [+offset_s, 0.0]) + test_plane_to_sphere(projection, (0.0, 0.0, -offset_p, 0.0), [-offset_s, 0.0]) + test_plane_to_sphere(projection, (0.0, 0.0, 0.0, +offset_p), [0.0, +offset_s]) + test_plane_to_sphere(projection, (0.0, 0.0, 0.0, -offset_p), [0.0, -offset_s]) + + +@pytest.mark.parametrize("projection, offset_p, offset_s", + # Reference point at pole on sphere + [('SIN', 1.0, PI/2), ('ARC', PI/2, PI/2), ('STG', 2.0, PI/2)]) +def test_plane_to_sphere_cross_pole(projection, offset_p, offset_s): + """Test four-point cross along axes, centred on pole of sphere (plane -> sphere).""" + test_plane_to_sphere(projection, (0.0, -PI/2, +offset_p, 0.0), [+offset_s, 0.0]) + test_plane_to_sphere(projection, (0.0, -PI/2, -offset_p, 0.0), [-offset_s, 0.0]) + test_plane_to_sphere(projection, (0.0, -PI/2, 0.0, +offset_p), [0.0, 0.0]) + test_plane_to_sphere(projection, (0.0, -PI/2, 0.0, -offset_p), [2 * offset_s, 0.0]) + + +@pytest.mark.parametrize("projection, offset_p", [('SIN', 2.0), ('ARC', 4.0), ('SSN', 2.0)]) +def test_plane_to_sphere_outside_domain(projection, offset_p): + """Test points outside allowed domain in plane (plane -> sphere).""" + test_plane_to_sphere(projection, (0.0, 0.0, offset_p, 0.0), None) + test_plane_to_sphere(projection, (0.0, 0.0, 0.0, offset_p), None) + + def sphere_to_plane_original_ssn(target_az, target_el, scan_az, scan_el): """Mattieu's original version of SSN projection.""" ll = np.cos(target_el) * np.sin(target_az - scan_az) From 08db7c5cee515274c0bfe05fc52c007ce42c5b05 Mon Sep 17 00:00:00 2001 From: Ludwig Schwardt Date: Mon, 13 Jul 2020 16:52:23 +0200 Subject: [PATCH 025/122] Fix random seeds in tests One day I'll switch to hypothesis, but for now fix those seeds. This is still done the old-fashioned way because NumPy 1.17 was only released last year and Astropy 3.2.3 + Python 3.5 only needs NumPy 1.13. The projection seeds are derived from the projection name to mix things up a bit (hopefully it doesn't cause trouble). --- katpoint/test/test_conversion.py | 2 ++ katpoint/test/test_pointing.py | 1 + katpoint/test/test_projection.py | 3 +++ katpoint/test/test_refraction.py | 1 + 4 files changed, 7 insertions(+) diff --git a/katpoint/test/test_conversion.py b/katpoint/test/test_conversion.py index 5f8b38b..500c667 100644 --- a/katpoint/test/test_conversion.py +++ b/katpoint/test/test_conversion.py @@ -30,6 +30,7 @@ class TestGeodetic: def setup(self): N = 1000 + np.random.seed(42) self.lat = 0.999 * np.pi * (np.random.rand(N) - 0.5) self.lon = 2.0 * np.pi * np.random.rand(N) self.alt = 1000.0 * np.random.randn(N) @@ -66,6 +67,7 @@ class TestSpherical: def setup(self): N = 1000 + np.random.seed(42) self.az = Angle(2.0 * np.pi * np.random.rand(N), unit=u.rad) self.el = Angle(0.999 * np.pi * (np.random.rand(N) - 0.5), unit=u.rad) diff --git a/katpoint/test/test_pointing.py b/katpoint/test/test_pointing.py index 77a8442..9c06b02 100644 --- a/katpoint/test/test_pointing.py +++ b/katpoint/test/test_pointing.py @@ -28,6 +28,7 @@ class TestPointingModel: """Test pointing model.""" def setup(self): + np.random.seed(42) az_range = katpoint.deg2rad(np.arange(-185.0, 275.0, 5.0)) el_range = katpoint.deg2rad(np.arange(0.0, 86.0, 1.0)) mesh_az, mesh_el = np.meshgrid(az_range, el_range) diff --git a/katpoint/test/test_projection.py b/katpoint/test/test_projection.py index aeb7a88..eb0409c 100644 --- a/katpoint/test/test_projection.py +++ b/katpoint/test/test_projection.py @@ -121,6 +121,7 @@ def test_random_closure(projection, decimal, N=100): """Do random projections and check closure.""" plane_to_sphere = katpoint.plane_to_sphere[projection] sphere_to_plane = katpoint.sphere_to_plane[projection] + np.random.seed(hash(projection) & (2 ** 32 - 1)) az0, el0, x, y = generate_data[projection](N) az, el = plane_to_sphere(az0, el0, x, y) xx, yy = sphere_to_plane(az0, el0, az, el) @@ -140,6 +141,7 @@ def test_aips_compatibility(projection, aips_code, decimal, N=100): """Compare with original AIPS routine (if available).""" plane_to_sphere = katpoint.plane_to_sphere[projection] sphere_to_plane = katpoint.sphere_to_plane[projection] + np.random.seed(hash(projection) & (2 ** 32 - 1)) az0, el0, x, y = generate_data[projection](N) if projection == 'TAN': # AIPS TAN only deprojects (x, y) coordinates within unit circle @@ -326,6 +328,7 @@ def plane_to_sphere_original_ssn(target_az, target_el, ll, mm): def test_vs_original_ssn(decimal=10, N=100): """SSN projection: compare against Mattieu's original version.""" plane_to_sphere = katpoint.plane_to_sphere['SSN'] + np.random.seed(hash('SSN') & (2 ** 32 - 1)) az0, el0, x, y = generate_data['SSN'](N) az, el = plane_to_sphere(az0, el0, x, y) ll, mm = sphere_to_plane_original_ssn(az0, el0, az, el) diff --git a/katpoint/test/test_refraction.py b/katpoint/test/test_refraction.py index b6cbf28..2e10315 100644 --- a/katpoint/test/test_refraction.py +++ b/katpoint/test/test_refraction.py @@ -45,6 +45,7 @@ def test_refraction_basic(self): def test_refraction_closure(self): """Test closure between refraction correction and its reverse operation.""" + np.random.seed(42) # Generate random meteorological data (hopefully sensible) - first only a single weather measurement temp = -10. + 50. * np.random.rand() pressure = 900. + 200. * np.random.rand() From 52c29572336c0fb48fd94fbaa72900a31f65756f Mon Sep 17 00:00:00 2001 From: Ludwig Schwardt Date: Tue, 14 Jul 2020 13:42:30 +0200 Subject: [PATCH 026/122] Move valid and invalid checks to separate tests These were artificially combined in `test_sphere_to_plane` and `test_plane_to_sphere`. Rather do away with the internal if-statement and make them separate tests. --- katpoint/test/test_projection.py | 61 +++++++++++++++++--------------- 1 file changed, 33 insertions(+), 28 deletions(-) diff --git a/katpoint/test/test_projection.py b/katpoint/test/test_projection.py index eb0409c..cfe7aad 100644 --- a/katpoint/test/test_projection.py +++ b/katpoint/test/test_projection.py @@ -189,19 +189,28 @@ def test_aips_compatibility(projection, aips_code, decimal, N=100): ('SSN', (0.0, PI/2, PI, 1e-8), [0.0, 1.0]), ('SSN', (0.0, PI/2, PI/2, 0.0), [0.0, 1.0]), ('SSN', (0.0, PI/2, -PI/2, 0.0), [0.0, 1.0]), - # Points outside allowed domain on sphere - ('ARC', (0.0, 0.0, 0.0, PI), None), ] ) def test_sphere_to_plane(projection, sphere, plane): """Test specific cases (sphere -> plane).""" sphere_to_plane = katpoint.sphere_to_plane[projection] - if plane is None: - with pytest.raises(ValueError): - sphere_to_plane(*sphere) - else: - xy = np.array(sphere_to_plane(*sphere)) - np.testing.assert_almost_equal(xy, plane, decimal=12) + xy = np.array(sphere_to_plane(*sphere)) + np.testing.assert_almost_equal(xy, plane, decimal=12) + + +@pytest.mark.parametrize("projection, sphere", [('ARC', (0.0, 0.0, 0.0, PI))]) +def test_sphere_to_plane_invalid(projection, sphere): + """Test points outside allowed domain on sphere (sphere -> plane).""" + sphere_to_plane = katpoint.sphere_to_plane[projection] + with pytest.raises(ValueError): + sphere_to_plane(*sphere) + + +@pytest.mark.parametrize("projection", ['SIN', 'TAN', 'STG', 'SSN']) +def test_sphere_to_plane_outside_domain(projection): + """Test points outside allowed domain on sphere (sphere -> plane).""" + test_sphere_to_plane_invalid(projection, (0.0, 0.0, PI, 0.0)) + test_sphere_to_plane_invalid(projection, (0.0, 0.0, 0.0, PI)) @pytest.mark.parametrize("projection", ['SIN', 'TAN', 'ARC', 'STG', 'SSN']) @@ -224,13 +233,6 @@ def test_sphere_to_plane_cross(projection, offset_s, offset_p): test_sphere_to_plane(projection, (0.0, 0.0, 0.0, -offset_s), [0.0, -offset_p]) -@pytest.mark.parametrize("projection", ['SIN', 'TAN', 'STG', 'SSN']) -def test_sphere_to_plane_outside_domain(projection): - """Test points outside allowed domain on sphere (sphere -> plane).""" - test_sphere_to_plane(projection, (0.0, 0.0, PI, 0.0), None) - test_sphere_to_plane(projection, (0.0, 0.0, 0.0, PI), None) - - def test_sphere_to_plane_special(): """Test special corner cases (sphere -> plane).""" sphere_to_plane = katpoint.sphere_to_plane['ARC'] @@ -262,12 +264,22 @@ def test_sphere_to_plane_special(): def test_plane_to_sphere(projection, plane, sphere): """Test specific cases (plane -> sphere).""" plane_to_sphere = katpoint.plane_to_sphere[projection] - if sphere is None: - with pytest.raises(ValueError): - plane_to_sphere(*plane) - else: - ae = np.array(plane_to_sphere(*plane)) - assert_angles_almost_equal(ae, sphere, decimal=12) + ae = np.array(plane_to_sphere(*plane)) + assert_angles_almost_equal(ae, sphere, decimal=12) + + +def plane_to_sphere_invalid(projection, plane): + """Test points outside allowed domain in plane (plane -> sphere).""" + plane_to_sphere = katpoint.plane_to_sphere[projection] + with pytest.raises(ValueError): + plane_to_sphere(*plane) + + +@pytest.mark.parametrize("projection, offset_p", [('SIN', 2.0), ('ARC', 4.0), ('SSN', 2.0)]) +def test_plane_to_sphere_outside_domain(projection, offset_p): + """Test points outside allowed domain in plane (plane -> sphere).""" + plane_to_sphere_invalid(projection, (0.0, 0.0, offset_p, 0.0)) + plane_to_sphere_invalid(projection, (0.0, 0.0, 0.0, offset_p)) @pytest.mark.parametrize("projection", ['SIN', 'TAN', 'ARC', 'STG', 'SSN']) @@ -301,13 +313,6 @@ def test_plane_to_sphere_cross_pole(projection, offset_p, offset_s): test_plane_to_sphere(projection, (0.0, -PI/2, 0.0, -offset_p), [2 * offset_s, 0.0]) -@pytest.mark.parametrize("projection, offset_p", [('SIN', 2.0), ('ARC', 4.0), ('SSN', 2.0)]) -def test_plane_to_sphere_outside_domain(projection, offset_p): - """Test points outside allowed domain in plane (plane -> sphere).""" - test_plane_to_sphere(projection, (0.0, 0.0, offset_p, 0.0), None) - test_plane_to_sphere(projection, (0.0, 0.0, 0.0, offset_p), None) - - def sphere_to_plane_original_ssn(target_az, target_el, scan_az, scan_el): """Mattieu's original version of SSN projection.""" ll = np.cos(target_el) * np.sin(target_az - scan_az) From 920577a4936dd59bef610ab3998bebaaee951f3e Mon Sep 17 00:00:00 2001 From: Ludwig Schwardt Date: Tue, 14 Jul 2020 16:34:58 +0200 Subject: [PATCH 027/122] More extensive pytestification Thanks to Bruce's suggestion to use autouse fixtures, I've now gone and stripped out a whole lot of test classes and replaced them with test functions, which play nicer with autouse. The setup methods were turned into local fixtures. A lot of the changes only involve a reduction in indentation or removal of `self`. Introduce parametrization where useful. --- katpoint/test/test_antenna.py | 133 ++++++------ katpoint/test/test_body.py | 192 +++++++++-------- katpoint/test/test_catalogue.py | 354 ++++++++++++++++--------------- katpoint/test/test_conversion.py | 95 +++++---- katpoint/test/test_delay.py | 53 +++-- katpoint/test/test_model.py | 149 +++++++------ katpoint/test/test_pointing.py | 138 ++++++------ katpoint/test/test_refraction.py | 77 ++++--- 8 files changed, 603 insertions(+), 588 deletions(-) diff --git a/katpoint/test/test_antenna.py b/katpoint/test/test_antenna.py index ddd9968..c655af5 100644 --- a/katpoint/test/test_antenna.py +++ b/katpoint/test/test_antenna.py @@ -19,77 +19,84 @@ import time import pickle -import numpy as np import pytest +import numpy as np import katpoint from .helper import assert_angles_almost_equal -class TestAntenna: - """Test :class:`katpoint.antenna.Antenna`.""" +@pytest.mark.parametrize( + "description", + [ + 'XDM, -25:53:23.0, 27:41:03.0, 1406.1086, 15.0', + 'FF1, -30:43:17.3, 21:24:38.5, 1038.0, 12.0, 18.4 -8.7 0.0', + ('FF2, -30:43:17.3, 21:24:38.5, 1038.0, 12.0, 86.2 25.5 0.0, ' + '-0:06:39.6 0 0 0 0 0 0:09:48.9, 1.16'), + ] +) +def test_construct_valid_antenna(description): + """Test construction of valid antennas from strings and vice versa.""" + # Normalise description string through one cycle to allow comparison + reference_description = katpoint.Antenna(description).description + test_antenna = katpoint.Antenna(reference_description) + assert test_antenna.description == reference_description, ( + 'Antenna description differs from original string') + assert test_antenna.description == test_antenna.format_katcp(), ( + 'Antenna description differs from KATCP format') + # Exercise repr() and str() + print('{!r} {}'.format(test_antenna, test_antenna)) + + +@pytest.mark.parametrize("description", ['XDM, -25:53:23.05075, 27:41:03.0', '']) +def test_construct_invalid_antenna(description): + """Test construction of invalid antennas from strings.""" + with pytest.raises(ValueError): + katpoint.Antenna(description) + + +def test_construct_antenna(): + """Test construction of antennas from strings and vice versa.""" + descr = katpoint.Antenna('XDM, -25:53:23.0, 27:41:03.0, 1406.1086, 15.0').description + assert descr == katpoint.Antenna(*descr.split(', ')).description + with pytest.raises(ValueError): + katpoint.Antenna(descr, *descr.split(', ')[1:]) + # Check that description string updates when object is updated + a1 = katpoint.Antenna('FF1, -30:43:17.3, 21:24:38.5, 1038.0, 12.0, 18.4 -8.7 0.0') + a2 = katpoint.Antenna('FF2, -30:43:17.3, 21:24:38.5, 1038.0, 13.0, 18.4 -8.7 0.0, 0.1, 1.22') + assert a1 != a2, 'Antennas should be inequal' + a1.name = 'FF2' + a1.diameter = 13.0 + a1.pointing_model = katpoint.PointingModel('0.1') + a1.beamwidth = 1.22 + assert a1.description == a2.description, 'Antenna description string not updated' + assert a1 == a2.description, 'Antenna not equal to description string' + assert a1 == a2, 'Antennas not equal' + assert a1 == katpoint.Antenna(a2), 'Construction with antenna object failed' + assert a1 == pickle.loads(pickle.dumps(a1)), 'Pickling failed' + try: + assert hash(a1) == hash(a2), 'Antenna hashes not equal' + except TypeError: + pytest.fail('Antenna object not hashable') - def setup(self): - self.valid_antennas = [ - 'XDM, -25:53:23.0, 27:41:03.0, 1406.1086, 15.0', - 'FF1, -30:43:17.3, 21:24:38.5, 1038.0, 12.0, 18.4 -8.7 0.0', - ('FF2, -30:43:17.3, 21:24:38.5, 1038.0, 12.0, 86.2 25.5 0.0, ' - '-0:06:39.6 0 0 0 0 0 0:09:48.9, 1.16') - ] - self.invalid_antennas = [ - 'XDM, -25:53:23.05075, 27:41:03.0', - '', - ] - self.timestamp = '2009/07/07 08:36:20' - def test_construct_antenna(self): - """Test construction of antennas from strings and vice versa.""" - valid_antennas = [katpoint.Antenna(descr) for descr in self.valid_antennas] - valid_strings = [a.description for a in valid_antennas] - for descr in valid_strings: - ant = katpoint.Antenna(descr) - print('%s %s' % (str(ant), repr(ant))) - assert descr == ant.description, 'Antenna description differs from original string' - assert ant.description == ant.format_katcp(), 'Antenna description differs from KATCP format' - for descr in self.invalid_antennas: - with pytest.raises(ValueError): - katpoint.Antenna(descr) - descr = valid_antennas[0].description - assert descr == katpoint.Antenna(*descr.split(', ')).description - with pytest.raises(ValueError): - katpoint.Antenna(descr, *descr.split(', ')[1:]) - # Check that description string updates when object is updated - a1 = katpoint.Antenna('FF1, -30:43:17.3, 21:24:38.5, 1038.0, 12.0, 18.4 -8.7 0.0') - a2 = katpoint.Antenna('FF2, -30:43:17.3, 21:24:38.5, 1038.0, 13.0, 18.4 -8.7 0.0, 0.1, 1.22') - assert a1 != a2, 'Antennas should be inequal' - a1.name = 'FF2' - a1.diameter = 13.0 - a1.pointing_model = katpoint.PointingModel('0.1') - a1.beamwidth = 1.22 - assert a1.description == a2.description, 'Antenna description string not updated' - assert a1 == a2.description, 'Antenna not equal to description string' - assert a1 == a2, 'Antennas not equal' - assert a1 == katpoint.Antenna(a2), 'Construction with antenna object failed' - assert a1 == pickle.loads(pickle.dumps(a1)), 'Pickling failed' - try: - assert hash(a1) == hash(a2), 'Antenna hashes not equal' - except TypeError: - self.fail('Antenna object not hashable') +def test_local_sidereal_time(): + """Test sidereal time and the use of date/time strings vs floats as timestamps.""" + ant = katpoint.Antenna('XDM, -25:53:23.0, 27:41:03.0, 1406.1086, 15.0') + timestamp = '2009/07/07 08:36:20' + utc_secs = time.mktime(time.strptime(timestamp, '%Y/%m/%d %H:%M:%S')) - time.timezone + sid1 = ant.local_sidereal_time(timestamp) + sid2 = ant.local_sidereal_time(utc_secs) + assert sid1 == sid2, 'Sidereal time differs for float and date/time string' + sid3 = ant.local_sidereal_time([timestamp, timestamp]) + sid4 = ant.local_sidereal_time([utc_secs, utc_secs]) + assert_angles_almost_equal(np.array([a.rad for a in sid3]), + np.array([a.rad for a in sid4]), decimal=12) - def test_local_sidereal_time(self): - """Test sidereal time and the use of date/time strings vs floats as timestamps.""" - ant = katpoint.Antenna(self.valid_antennas[0]) - utc_secs = time.mktime(time.strptime(self.timestamp, '%Y/%m/%d %H:%M:%S')) - time.timezone - sid1 = ant.local_sidereal_time(self.timestamp) - sid2 = ant.local_sidereal_time(utc_secs) - assert sid1 == sid2, 'Sidereal time differs for float and date/time string' - sid3 = ant.local_sidereal_time([self.timestamp, self.timestamp]) - sid4 = ant.local_sidereal_time([utc_secs, utc_secs]) - assert_angles_almost_equal(np.array([a.rad for a in sid3]), - np.array([a.rad for a in sid4]), decimal=12) - def test_array_reference_antenna(self): - ant = katpoint.Antenna(self.valid_antennas[2]) - ref_ant = ant.array_reference_antenna() - assert ref_ant.description == 'array, -30:43:17.3, 21:24:38.5, 1038, 12.0, , , 1.16' +def test_array_reference_antenna(): + ant = katpoint.Antenna('FF2, -30:43:17.3, 21:24:38.5, 1038.0, 12.0, 86.2 25.5 0.0, ' + '-0:06:39.6 0 0 0 0 0 0:09:48.9, 1.16') + ref_ant = ant.array_reference_antenna() + assert ref_ant.description == 'array, -30:43:17.3, 21:24:38.5, 1038, 12.0, , , 1.16' diff --git a/katpoint/test/test_body.py b/katpoint/test/test_body.py index bb9eccb..62617b1 100644 --- a/katpoint/test/test_body.py +++ b/katpoint/test/test_body.py @@ -21,12 +21,12 @@ """ +import pytest import numpy as np from numpy.testing import assert_allclose import astropy.units as u from astropy.coordinates import SkyCoord, ICRS, EarthLocation, Latitude, Longitude from astropy.time import Time -import pytest from katpoint.bodies import FixedBody, Sun, Moon, Mars, EarthSatellite, readtle @@ -38,99 +38,97 @@ def _get_earth_satellite(): return readtle(name, line1, line2) -class TestBody: - """Test computing with various Bodies.""" - - @pytest.mark.parametrize( - "body, date_str, ra_str, dec_str, az_str, el_str", - [ - (FixedBody(), '2020-01-01 00:00:00.000', - '10:10:40.123', '40:20:50.567', '326:05:57.541', '51:21:20.0119'), - # 326:05:54.8, 51:21:18.5 (PyEphem) - (Mars(), '2020-01-01 00:00:00.000', - '', '', '118:10:05.1129', '27:23:12.8499'), - # 118:10:06.1, 27:23:13.3 (PyEphem) - (Moon(), '2020-01-01 10:00:00.000', - '', '', '127:15:17.1381', '60:05:10.2438'), - # 127:15:23.6, 60:05:13.7 (PyEphem) - (Sun(), '2020-01-01 10:00:00.000', - '', '', '234:53:19.4835', '31:38:11.412'), - # 234:53:20.8, 31:38:09.4 (PyEphem) - (_get_earth_satellite(), '2019-09-23 07:45:36.000', - '3:32:56.7813', '-2:04:35.4329', '280:32:29.675', '-54:06:50.7456'), - # 3:32:59.21 -2:04:36.3 280:32:07.2 -54:06:14.4 (PyEphem) - ] - ) - def test_compute(self, body, date_str, ra_str, dec_str, az_str, el_str): - """Test compute method""" - lat = Latitude('10:00:00.000', unit=u.deg) - lon = Longitude('80:00:00.000', unit=u.deg) - date = Time(date_str) - - if isinstance(body, FixedBody): - ra = Longitude(ra_str, unit=u.hour) - dec = Latitude(dec_str, unit=u.deg) - body._radec = SkyCoord(ra=ra, dec=dec, frame=ICRS) - height = 4200.0 if isinstance(body, EarthSatellite) else 0.0 - body.compute(EarthLocation(lat=lat, lon=lon, height=height), date, 0.0) - - if ra_str and dec_str: - assert body.a_radec.ra.to_string(sep=':', unit=u.hour) == ra_str - assert body.a_radec.dec.to_string(sep=':') == dec_str - assert body.altaz.az.to_string(sep=':') == az_str - assert body.altaz.alt.to_string(sep=':') == el_str - - def test_earth_satellite(self): - sat = _get_earth_satellite() - # Check that the EarthSatellite object has the expect attribute values. - assert str(sat._epoch) == '2019-09-23 07:45:35.842' - assert sat._inc == np.deg2rad(55.4408) - assert sat._raan == np.deg2rad(61.3790) - assert sat._e == 0.0191986 - assert sat._ap == np.deg2rad(78.1802) - assert sat._M == np.deg2rad(283.9935) - assert sat._n == 2.0056172 - assert sat._decay == 1.2e-07 - assert sat._orbit == 10428 - assert sat._drag == 1.e-04 - - # This is xephem database record that pyephem generates - xephem = ' GPS BIIA-21 (PRN 09) ,E,9/23.32333151/2019| 6/15.3242/2019| 1/1.32422/2020,' \ - '55.4408,61.379002,0.0191986,78.180199,283.9935,2.0056172,1.2e-07,10428,9.9999997e-05' - - rec = sat.writedb() - assert rec.split(',')[0] == xephem.split(',')[0] - assert rec.split(',')[1] == xephem.split(',')[1] - - assert (rec.split(',')[2].split('|')[0].split('/')[0] - == xephem.split(',')[2].split('|')[0].split('/')[0]) - assert_allclose(float(rec.split(',')[2].split('|')[0].split('/')[1]), - float(xephem.split(',')[2].split('|')[0].split('/')[1]), rtol=0, atol=0.5e-7) - assert (rec.split(',')[2].split('|')[0].split('/')[2] - == xephem.split(',')[2].split('|')[0].split('/')[2]) - - assert (rec.split(',')[2].split('|')[1].split('/')[0] - == xephem.split(',')[2].split('|')[1].split('/')[0]) - assert_allclose(float(rec.split(',')[2].split('|')[1].split('/')[1]), - float(xephem.split(',')[2].split('|')[1].split('/')[1]), rtol=0, atol=0.5e-2) - assert (rec.split(',')[2].split('|')[1].split('/')[2] - == xephem.split(',')[2].split('|')[1].split('/')[2]) - - assert (rec.split(',')[2].split('|')[2].split('/')[0] - == xephem.split(',')[2].split('|')[2].split('/')[0]) - assert_allclose(float(rec.split(',')[2].split('|')[2].split('/')[1]), - float(xephem.split(',')[2].split('|')[2].split('/')[1]), rtol=0, atol=0.5e-2) - assert (rec.split(',')[2].split('|')[2].split('/')[2] - == xephem.split(',')[2].split('|')[2].split('/')[2]) - - assert rec.split(',')[3] == xephem.split(',')[3] - - # pyephem adds spurious precision to these 3 fields - assert rec.split(',')[4] == xephem.split(',')[4][:6] - assert rec.split(',')[5][:7] == xephem.split(',')[5][:7] - assert rec.split(',')[6] == xephem.split(',')[6][:5] - - assert rec.split(',')[7] == xephem.split(',')[7] - assert rec.split(',')[8] == xephem.split(',')[8] - assert rec.split(',')[9] == xephem.split(',')[9] - assert rec.split(',')[10] == xephem.split(',')[10] +@pytest.mark.parametrize( + "body, date_str, ra_str, dec_str, az_str, el_str", + [ + (FixedBody(), '2020-01-01 00:00:00.000', + '10:10:40.123', '40:20:50.567', '326:05:57.541', '51:21:20.0119'), + # 326:05:54.8, 51:21:18.5 (PyEphem) + (Mars(), '2020-01-01 00:00:00.000', + '', '', '118:10:05.1129', '27:23:12.8499'), + # 118:10:06.1, 27:23:13.3 (PyEphem) + (Moon(), '2020-01-01 10:00:00.000', + '', '', '127:15:17.1381', '60:05:10.2438'), + # 127:15:23.6, 60:05:13.7 (PyEphem) + (Sun(), '2020-01-01 10:00:00.000', + '', '', '234:53:19.4835', '31:38:11.412'), + # 234:53:20.8, 31:38:09.4 (PyEphem) + (_get_earth_satellite(), '2019-09-23 07:45:36.000', + '3:32:56.7813', '-2:04:35.4329', '280:32:29.675', '-54:06:50.7456'), + # 3:32:59.21 -2:04:36.3 280:32:07.2 -54:06:14.4 (PyEphem) + ] +) +def test_compute(body, date_str, ra_str, dec_str, az_str, el_str): + """Test compute method""" + lat = Latitude('10:00:00.000', unit=u.deg) + lon = Longitude('80:00:00.000', unit=u.deg) + date = Time(date_str) + + if isinstance(body, FixedBody): + ra = Longitude(ra_str, unit=u.hour) + dec = Latitude(dec_str, unit=u.deg) + body._radec = SkyCoord(ra=ra, dec=dec, frame=ICRS) + height = 4200.0 if isinstance(body, EarthSatellite) else 0.0 + body.compute(EarthLocation(lat=lat, lon=lon, height=height), date, 0.0) + + if ra_str and dec_str: + assert body.a_radec.ra.to_string(sep=':', unit=u.hour) == ra_str + assert body.a_radec.dec.to_string(sep=':') == dec_str + assert body.altaz.az.to_string(sep=':') == az_str + assert body.altaz.alt.to_string(sep=':') == el_str + + +def test_earth_satellite(): + sat = _get_earth_satellite() + # Check that the EarthSatellite object has the expect attribute values. + assert str(sat._epoch) == '2019-09-23 07:45:35.842' + assert sat._inc == np.deg2rad(55.4408) + assert sat._raan == np.deg2rad(61.3790) + assert sat._e == 0.0191986 + assert sat._ap == np.deg2rad(78.1802) + assert sat._M == np.deg2rad(283.9935) + assert sat._n == 2.0056172 + assert sat._decay == 1.2e-07 + assert sat._orbit == 10428 + assert sat._drag == 1.e-04 + + # This is xephem database record that pyephem generates + xephem = ' GPS BIIA-21 (PRN 09) ,E,9/23.32333151/2019| 6/15.3242/2019| 1/1.32422/2020,' \ + '55.4408,61.379002,0.0191986,78.180199,283.9935,2.0056172,1.2e-07,10428,9.9999997e-05' + + rec = sat.writedb() + assert rec.split(',')[0] == xephem.split(',')[0] + assert rec.split(',')[1] == xephem.split(',')[1] + + assert (rec.split(',')[2].split('|')[0].split('/')[0] + == xephem.split(',')[2].split('|')[0].split('/')[0]) + assert_allclose(float(rec.split(',')[2].split('|')[0].split('/')[1]), + float(xephem.split(',')[2].split('|')[0].split('/')[1]), rtol=0, atol=0.5e-7) + assert (rec.split(',')[2].split('|')[0].split('/')[2] + == xephem.split(',')[2].split('|')[0].split('/')[2]) + + assert (rec.split(',')[2].split('|')[1].split('/')[0] + == xephem.split(',')[2].split('|')[1].split('/')[0]) + assert_allclose(float(rec.split(',')[2].split('|')[1].split('/')[1]), + float(xephem.split(',')[2].split('|')[1].split('/')[1]), rtol=0, atol=0.5e-2) + assert (rec.split(',')[2].split('|')[1].split('/')[2] + == xephem.split(',')[2].split('|')[1].split('/')[2]) + + assert (rec.split(',')[2].split('|')[2].split('/')[0] + == xephem.split(',')[2].split('|')[2].split('/')[0]) + assert_allclose(float(rec.split(',')[2].split('|')[2].split('/')[1]), + float(xephem.split(',')[2].split('|')[2].split('/')[1]), rtol=0, atol=0.5e-2) + assert (rec.split(',')[2].split('|')[2].split('/')[2] + == xephem.split(',')[2].split('|')[2].split('/')[2]) + + assert rec.split(',')[3] == xephem.split(',')[3] + + # pyephem adds spurious precision to these 3 fields + assert rec.split(',')[4] == xephem.split(',')[4][:6] + assert rec.split(',')[5][:7] == xephem.split(',')[5][:7] + assert rec.split(',')[6] == xephem.split(',')[6][:5] + + assert rec.split(',')[7] == xephem.split(',')[7] + assert rec.split(',')[8] == xephem.split(',')[8] + assert rec.split(',')[9] == xephem.split(',')[9] + assert rec.split(',')[10] == xephem.split(',')[10] diff --git a/katpoint/test/test_catalogue.py b/katpoint/test/test_catalogue.py index 01f5346..65ecbbf 100644 --- a/katpoint/test/test_catalogue.py +++ b/katpoint/test/test_catalogue.py @@ -18,8 +18,8 @@ import time -from numpy.testing import assert_allclose import pytest +from numpy.testing import assert_allclose import katpoint @@ -28,178 +28,182 @@ YY = time.localtime().tm_year % 100 -class TestCatalogueConstruction: +def test_catalogue_basic(): + """Basic catalogue tests.""" + cat = katpoint.Catalogue(add_specials=True) + repr(cat) + str(cat) + cat.add('# Comments will be ignored') + with pytest.raises(ValueError): + cat.add([1]) + + +def test_catalogue_tab_completion(): + cat = katpoint.Catalogue() + cat.add('Nothing, special') + cat.add('Earth | Terra Incognita, azel, 0, 0') + cat.add('Earth | Sky, azel, 0, 90') + # Check that it returns a sorted list + assert cat._ipython_key_completions_() == ['Earth', 'Nothing', 'Sky', 'Terra Incognita'] + + +def test_catalogue_same_name(): + """"Test add() and remove() of targets with the same name.""" + cat = katpoint.Catalogue() + targets = ['Sun, special', 'Sun | Sol, special', 'Sun, special hot'] + # Add various targets called Sun + cat.add(targets[0]) + assert cat['Sun'].description == targets[0] + cat.add(targets[0]) + assert len(cat) == 1, 'Did not ignore duplicate target' + cat.add(targets[1]) + assert cat['Sun'].description == targets[1] + cat.add(targets[2]) + assert cat['Sun'].description == targets[2] + # Check length, iteration, membership + assert len(cat) == len(targets) + for n, t in enumerate(cat): + assert t.description == targets[n] + assert 'Sun' in cat + assert 'Sol' in cat + for t in targets: + assert katpoint.Target(t) in cat + # Remove targets one by one + cat.remove('Sun') + assert cat['Sun'].description == targets[1] + cat.remove('Sun') + assert cat['Sun'].description == targets[0] + cat.remove('Sun') + assert len(cat) == len(cat.targets) == len(cat.lookup) == 0, 'Catalogue not empty' + + +def test_construct_catalogue(): """Test construction of catalogues.""" - - def setup(self): - self.tle_lines = [ - '# Comment ignored\n', - 'GPS BIIA-21 (PRN 09) \n', - '1 22700U 93042A %02d266.32333151 .00000012 00000-0 10000-3 0 805%1d\n' - % (YY, (YY // 10 + YY - 7 + 4) % 10), - '2 22700 55.4408 61.3790 0191986 78.1802 283.9935 2.00561720104282\n'] - self.edb_lines = ['# Comment ignored\n', - 'HIC 13847,f|S|A4,2:58:16.03,-40:18:17.1,2.906,2000,\n'] - self.antenna = katpoint.Antenna('XDM, -25:53:23.05075, 27:41:03.36453, 1406.1086, 15.0') - - def test_catalogue_basic(self): - """Basic catalogue tests.""" - cat = katpoint.Catalogue(add_specials=True) - repr(cat) - str(cat) - cat.add('# Comments will be ignored') - with pytest.raises(ValueError): - cat.add([1]) - - def test_catalogue_tab_completion(self): - cat = katpoint.Catalogue() - cat.add('Nothing, special') - cat.add('Earth | Terra Incognita, azel, 0, 0') - cat.add('Earth | Sky, azel, 0, 90') - # Check that it returns a sorted list - assert cat._ipython_key_completions_() == ['Earth', 'Nothing', 'Sky', 'Terra Incognita'] - - def test_catalogue_same_name(self): - """"Test add() and remove() of targets with the same name.""" - cat = katpoint.Catalogue() - targets = ['Sun, special', 'Sun | Sol, special', 'Sun, special hot'] - # Add various targets called Sun - cat.add(targets[0]) - assert cat['Sun'].description == targets[0] - cat.add(targets[0]) - assert len(cat) == 1, 'Did not ignore duplicate target' - cat.add(targets[1]) - assert cat['Sun'].description == targets[1] - cat.add(targets[2]) - assert cat['Sun'].description == targets[2] - # Check length, iteration, membership - assert len(cat) == len(targets) - for n, t in enumerate(cat): - assert t.description == targets[n] - assert 'Sun' in cat - assert 'Sol' in cat - for t in targets: - assert katpoint.Target(t) in cat - # Remove targets one by one - cat.remove('Sun') - assert cat['Sun'].description == targets[1] - cat.remove('Sun') - assert cat['Sun'].description == targets[0] - cat.remove('Sun') - assert len(cat) == len(cat.targets) == len(cat.lookup) == 0, 'Catalogue not empty' - - def test_construct_catalogue(self): - """Test construction of catalogues.""" - cat = katpoint.Catalogue(add_specials=True, add_stars=True, antenna=self.antenna) - num_targets_original = len(cat) - assert num_targets_original == len(katpoint.specials) + 1 + len(katpoint.stars.stars) - # Add target already in catalogue - no action - cat.add(katpoint.Target('Sun, special')) - num_targets = len(cat) - assert num_targets == num_targets_original, 'Number of targets incorrect' - cat2 = katpoint.Catalogue(add_specials=True, add_stars=True) - cat2.add(katpoint.Target('Sun, special')) - assert cat == cat2, 'Catalogues not equal' - try: - assert hash(cat) == hash(cat2), 'Catalogue hashes not equal' - except TypeError: - pytest.fail('Catalogue object not hashable') - # Add different targets with the same name - cat2.add(katpoint.Target('Sun, special hot')) - cat2.add(katpoint.Target('Sun | Sol, special')) - assert len(cat2) == num_targets_original + 2, 'Number of targets incorrect' - cat2.remove('Sol') - assert len(cat2) == num_targets_original + 1, 'Number of targets incorrect' - assert cat != cat2, 'Catalogues should not be equal' - test_target = cat.targets[-1] - assert test_target.description == cat[test_target.name].description, 'Lookup failed' - assert cat['Non-existent'] is None, 'Lookup of non-existent target failed' - cat.add_tle(self.tle_lines, 'tle') - cat.add_edb(self.edb_lines, 'edb') - assert len(cat.targets) == num_targets + 2, 'Number of targets incorrect' - cat.remove(cat.targets[-1].name) - assert len(cat.targets) == num_targets + 1, 'Number of targets incorrect' - closest_target, dist = cat.closest_to(test_target) - assert closest_target.description == test_target.description, 'Closest target incorrect' - assert_allclose(dist, 0.0, rtol=0.0, atol=0.5e-5, - err_msg='Target should be on top of itself') - - def test_that_equality_and_hash_ignore_order(self): - a = katpoint.Catalogue() - b = katpoint.Catalogue() - t1 = katpoint.Target('Nothing, special') - t2 = katpoint.Target('Sun, special') - a.add(t1) - a.add(t2) - b.add(t2) - b.add(t1) - assert a == b, 'Shuffled catalogues are not equal' - assert hash(a) == hash(b), 'Shuffled catalogues have different hashes' - - def test_skip_empty(self): - cat = katpoint.Catalogue(['', '# comment', ' ', '\t\r ']) - assert len(cat) == 0 - - -class TestCatalogueFilterSort: - """Test filtering and sorting of catalogues.""" - - def setup(self): - self.flux_target = katpoint.Target('flux, radec, 0.0, 0.0, (1.0 2.0 2.0 0.0 0.0)') - self.antenna = katpoint.Antenna('XDM, -25:53:23.05075, 27:41:03.36453, 1406.1086, 15.0') - self.antenna2 = katpoint.Antenna('XDM2, -25:53:23.05075, 27:41:03.36453, ' - '1406.1086, 15.0, 100.0 0.0 0.0') - self.timestamp = time.mktime(time.strptime('2009/06/14 12:34:56', '%Y/%m/%d %H:%M:%S')) - - def test_filter_catalogue(self): - """Test filtering of catalogues.""" - cat = katpoint.Catalogue(add_specials=True, add_stars=True) - cat = cat.filter(tags=['special', '~radec']) - assert len(cat.targets) == len(katpoint.specials), 'Number of targets incorrect' - cat.add(self.flux_target) - cat2 = cat.filter(flux_limit_Jy=50.0, flux_freq_MHz=1.5) - assert len(cat2.targets) == 1, 'Number of targets with sufficient flux should be 1' - assert cat != cat2, 'Catalogues should be inequal' - cat3 = cat.filter(az_limit_deg=[0, 180], timestamp=self.timestamp, antenna=self.antenna) - assert len(cat3.targets) == 1, 'Number of targets rising should be 1' - cat4 = cat.filter(az_limit_deg=[180, 0], timestamp=self.timestamp, antenna=self.antenna) - assert len(cat4.targets) == 9, 'Number of targets setting should be 9' - cat.add(katpoint.Target('Zenith, azel, 0, 90')) - cat5 = cat.filter(el_limit_deg=85, timestamp=self.timestamp, antenna=self.antenna) - assert len(cat5.targets) == 1, 'Number of targets close to zenith should be 1' - sun = katpoint.Target('Sun, special') - cat6 = cat.filter(dist_limit_deg=[0.0, 1.0], proximity_targets=sun, - timestamp=self.timestamp, antenna=self.antenna) - assert len(cat6.targets) == 1, 'Number of targets close to Sun should be 1' - - def test_sort_catalogue(self): - """Test sorting of catalogues.""" - cat = katpoint.Catalogue(add_specials=True, add_stars=True) - assert len(cat.targets) == len(katpoint.specials) + 1 + len(katpoint.stars.stars) - cat1 = cat.sort(key='name') - assert cat1 == cat, 'Catalogue equality failed' - assert cat1.targets[0].name == 'Acamar', 'Sorting on name failed' - cat2 = cat.sort(key='ra', timestamp=self.timestamp, antenna=self.antenna) - assert cat2.targets[0].name == 'Alpheratz', 'Sorting on ra failed' - cat3 = cat.sort(key='dec', timestamp=self.timestamp, antenna=self.antenna) - assert cat3.targets[0].name == 'Miaplacidus', 'Sorting on dec failed' - cat4 = cat.sort(key='az', timestamp=self.timestamp, antenna=self.antenna, ascending=False) - assert cat4.targets[0].name == 'Polaris', 'Sorting on az failed' # az: 359:25:07.3 - cat5 = cat.sort(key='el', timestamp=self.timestamp, antenna=self.antenna) - assert cat5.targets[-1].name == 'Zenith', 'Sorting on el failed' # el: 90:00:00.0 - cat.add(self.flux_target) - cat6 = cat.sort(key='flux', ascending=False, flux_freq_MHz=1.5) - assert 'flux' in (cat6.targets[0].name, cat6.targets[-1].name), ( - 'Flux target should be at start or end of catalogue after sorting') - assert ((cat6.targets[0].flux_density(1.5) == 100.0) or - (cat6.targets[-1].flux_density(1.5) == 100.0)), 'Sorting on flux failed' - - def test_visibility_list(self): - """Test output of visibility list.""" - cat = katpoint.Catalogue(add_specials=True, add_stars=True) - cat.add(self.flux_target) - cat.remove('Zenith') - cat.visibility_list(timestamp=self.timestamp, antenna=self.antenna, flux_freq_MHz=1.5, antenna2=self.antenna2) - cat.antenna = self.antenna - cat.flux_freq_MHz = 1.5 - cat.visibility_list(timestamp=self.timestamp) + antenna = katpoint.Antenna('XDM, -25:53:23.05075, 27:41:03.36453, 1406.1086, 15.0') + cat = katpoint.Catalogue(add_specials=True, add_stars=True, antenna=antenna) + num_targets_original = len(cat) + assert num_targets_original == len(katpoint.specials) + 1 + len(katpoint.stars.stars) + # Add target already in catalogue - no action + cat.add(katpoint.Target('Sun, special')) + num_targets = len(cat) + assert num_targets == num_targets_original, 'Number of targets incorrect' + cat2 = katpoint.Catalogue(add_specials=True, add_stars=True) + cat2.add(katpoint.Target('Sun, special')) + assert cat == cat2, 'Catalogues not equal' + try: + assert hash(cat) == hash(cat2), 'Catalogue hashes not equal' + except TypeError: + pytest.fail('Catalogue object not hashable') + # Add different targets with the same name + cat2.add(katpoint.Target('Sun, special hot')) + cat2.add(katpoint.Target('Sun | Sol, special')) + assert len(cat2) == num_targets_original + 2, 'Number of targets incorrect' + cat2.remove('Sol') + assert len(cat2) == num_targets_original + 1, 'Number of targets incorrect' + assert cat != cat2, 'Catalogues should not be equal' + test_target = cat.targets[-1] + assert test_target.description == cat[test_target.name].description, 'Lookup failed' + assert cat['Non-existent'] is None, 'Lookup of non-existent target failed' + tle_lines = ['# Comment ignored\n', + 'GPS BIIA-21 (PRN 09) \n', + '1 22700U 93042A %02d266.32333151 .00000012 00000-0 10000-3 0 805%1d\n' + % (YY, (YY // 10 + YY - 7 + 4) % 10), + '2 22700 55.4408 61.3790 0191986 78.1802 283.9935 2.00561720104282\n'] + cat.add_tle(tle_lines, 'tle') + edb_lines = ['# Comment ignored\n', + 'HIC 13847,f|S|A4,2:58:16.03,-40:18:17.1,2.906,2000,\n'] + cat.add_edb(edb_lines, 'edb') + assert len(cat.targets) == num_targets + 2, 'Number of targets incorrect' + cat.remove(cat.targets[-1].name) + assert len(cat.targets) == num_targets + 1, 'Number of targets incorrect' + closest_target, dist = cat.closest_to(test_target) + assert closest_target.description == test_target.description, 'Closest target incorrect' + assert_allclose(dist, 0.0, rtol=0.0, atol=0.5e-5, + err_msg='Target should be on top of itself') + + +def test_that_equality_and_hash_ignore_order(): + a = katpoint.Catalogue() + b = katpoint.Catalogue() + t1 = katpoint.Target('Nothing, special') + t2 = katpoint.Target('Sun, special') + a.add(t1) + a.add(t2) + b.add(t2) + b.add(t1) + assert a == b, 'Shuffled catalogues are not equal' + assert hash(a) == hash(b), 'Shuffled catalogues have different hashes' + + +def test_skip_empty(): + cat = katpoint.Catalogue(['', '# comment', ' ', '\t\r ']) + assert len(cat) == 0 + + +@pytest.fixture +def filter_catalogue_parts(): + flux_target = katpoint.Target('flux, radec, 0.0, 0.0, (1.0 2.0 2.0 0.0 0.0)') + antenna = katpoint.Antenna('XDM, -25:53:23.05075, 27:41:03.36453, 1406.1086, 15.0') + timestamp = time.mktime(time.strptime('2009/06/14 12:34:56', '%Y/%m/%d %H:%M:%S')) + return antenna, timestamp, flux_target + + +def test_filter_catalogue(filter_catalogue_parts): + """Test filtering of catalogues.""" + antenna, timestamp, flux_target = filter_catalogue_parts + cat = katpoint.Catalogue(add_specials=True, add_stars=True) + cat = cat.filter(tags=['special', '~radec']) + assert len(cat.targets) == len(katpoint.specials), 'Number of targets incorrect' + cat.add(flux_target) + cat2 = cat.filter(flux_limit_Jy=50.0, flux_freq_MHz=1.5) + assert len(cat2.targets) == 1, 'Number of targets with sufficient flux should be 1' + assert cat != cat2, 'Catalogues should be inequal' + cat3 = cat.filter(az_limit_deg=[0, 180], timestamp=timestamp, antenna=antenna) + assert len(cat3.targets) == 1, 'Number of targets rising should be 1' + cat4 = cat.filter(az_limit_deg=[180, 0], timestamp=timestamp, antenna=antenna) + assert len(cat4.targets) == 9, 'Number of targets setting should be 9' + cat.add(katpoint.Target('Zenith, azel, 0, 90')) + cat5 = cat.filter(el_limit_deg=85, timestamp=timestamp, antenna=antenna) + assert len(cat5.targets) == 1, 'Number of targets close to zenith should be 1' + sun = katpoint.Target('Sun, special') + cat6 = cat.filter(dist_limit_deg=[0.0, 1.0], proximity_targets=sun, + timestamp=timestamp, antenna=antenna) + assert len(cat6.targets) == 1, 'Number of targets close to Sun should be 1' + + +def test_sort_catalogue(filter_catalogue_parts): + """Test sorting of catalogues.""" + antenna, timestamp, flux_target = filter_catalogue_parts + cat = katpoint.Catalogue(add_specials=True, add_stars=True) + assert len(cat.targets) == len(katpoint.specials) + 1 + len(katpoint.stars.stars) + cat1 = cat.sort(key='name') + assert cat1 == cat, 'Catalogue equality failed' + assert cat1.targets[0].name == 'Acamar', 'Sorting on name failed' + cat2 = cat.sort(key='ra', timestamp=timestamp, antenna=antenna) + assert cat2.targets[0].name == 'Alpheratz', 'Sorting on ra failed' + cat3 = cat.sort(key='dec', timestamp=timestamp, antenna=antenna) + assert cat3.targets[0].name == 'Miaplacidus', 'Sorting on dec failed' + cat4 = cat.sort(key='az', timestamp=timestamp, antenna=antenna, ascending=False) + assert cat4.targets[0].name == 'Polaris', 'Sorting on az failed' # az: 359:25:07.3 + cat5 = cat.sort(key='el', timestamp=timestamp, antenna=antenna) + assert cat5.targets[-1].name == 'Zenith', 'Sorting on el failed' # el: 90:00:00.0 + cat.add(flux_target) + cat6 = cat.sort(key='flux', ascending=False, flux_freq_MHz=1.5) + assert 'flux' in (cat6.targets[0].name, cat6.targets[-1].name), ( + 'Flux target should be at start or end of catalogue after sorting') + assert ((cat6.targets[0].flux_density(1.5) == 100.0) or + (cat6.targets[-1].flux_density(1.5) == 100.0)), 'Sorting on flux failed' + + +def test_visibility_list(filter_catalogue_parts): + """Test output of visibility list.""" + antenna, timestamp, flux_target = filter_catalogue_parts + antenna2 = katpoint.Antenna('XDM2, -25:53:23.05075, 27:41:03.36453, ' + '1406.1086, 15.0, 100.0 0.0 0.0') + cat = katpoint.Catalogue(add_specials=True, add_stars=True) + cat.add(flux_target) + cat.remove('Zenith') + cat.visibility_list(timestamp=timestamp, antenna=antenna, flux_freq_MHz=1.5, antenna2=antenna2) + cat.antenna = antenna + cat.flux_freq_MHz = 1.5 + cat.visibility_list(timestamp=timestamp) diff --git a/katpoint/test/test_conversion.py b/katpoint/test/test_conversion.py index 500c667..ad9b050 100644 --- a/katpoint/test/test_conversion.py +++ b/katpoint/test/test_conversion.py @@ -16,6 +16,7 @@ """Tests for the conversion module.""" +import pytest import numpy as np import astropy.units as u from astropy.coordinates import Angle @@ -25,55 +26,59 @@ from .helper import assert_angles_almost_equal -class TestGeodetic: - """Closure tests for geodetic coordinate transformations.""" +@pytest.fixture +def random_geoid(): + N = 1000 + np.random.seed(42) + lat = 0.999 * np.pi * (np.random.rand(N) - 0.5) + lon = 2.0 * np.pi * np.random.rand(N) + alt = 1000.0 * np.random.randn(N) + return lat, lon, alt - def setup(self): - N = 1000 - np.random.seed(42) - self.lat = 0.999 * np.pi * (np.random.rand(N) - 0.5) - self.lon = 2.0 * np.pi * np.random.rand(N) - self.alt = 1000.0 * np.random.randn(N) - def test_lla_to_ecef(self): - """Closure tests for LLA to ECEF conversion and vice versa.""" - x, y, z = katpoint.lla_to_ecef(self.lat, self.lon, self.alt) - new_lat, new_lon, new_alt = katpoint.ecef_to_lla(x, y, z) - new_x, new_y, new_z = katpoint.lla_to_ecef(new_lat, new_lon, new_alt) - assert_angles_almost_equal(new_lat, self.lat, decimal=12) - assert_angles_almost_equal(new_lon, self.lon, decimal=12) - assert_angles_almost_equal(new_alt, self.alt, decimal=6) - np.testing.assert_almost_equal(new_x, x, decimal=8) - np.testing.assert_almost_equal(new_y, y, decimal=8) - np.testing.assert_almost_equal(new_z, z, decimal=6) - if hasattr(katpoint, '_conversion'): - new_lat2, new_lon2, new_alt2 = katpoint._conversion.ecef_to_lla2(x, y, z) - assert_angles_almost_equal(new_lat2, self.lat, decimal=12) - assert_angles_almost_equal(new_lon2, self.lon, decimal=12) - assert_angles_almost_equal(new_alt2, self.alt, decimal=6) +def test_lla_to_ecef(random_geoid): + """Closure tests for LLA to ECEF conversion and vice versa.""" + lat, lon, alt = random_geoid + x, y, z = katpoint.lla_to_ecef(lat, lon, alt) + new_lat, new_lon, new_alt = katpoint.ecef_to_lla(x, y, z) + new_x, new_y, new_z = katpoint.lla_to_ecef(new_lat, new_lon, new_alt) + assert_angles_almost_equal(new_lat, lat, decimal=12) + assert_angles_almost_equal(new_lon, lon, decimal=12) + assert_angles_almost_equal(new_alt, alt, decimal=6) + np.testing.assert_almost_equal(new_x, x, decimal=8) + np.testing.assert_almost_equal(new_y, y, decimal=8) + np.testing.assert_almost_equal(new_z, z, decimal=6) + if hasattr(katpoint, '_conversion'): + new_lat2, new_lon2, new_alt2 = katpoint._conversion.ecef_to_lla2(x, y, z) + assert_angles_almost_equal(new_lat2, lat, decimal=12) + assert_angles_almost_equal(new_lon2, lon, decimal=12) + assert_angles_almost_equal(new_alt2, alt, decimal=6) - def test_ecef_to_enu(self): - """Closure tests for ECEF to ENU conversion and vice versa.""" - x, y, z = katpoint.lla_to_ecef(self.lat, self.lon, self.alt) - e, n, u = katpoint.ecef_to_enu(self.lat[0], self.lon[0], self.alt[0], x, y, z) - new_x, new_y, new_z = katpoint.enu_to_ecef(self.lat[0], self.lon[0], self.alt[0], e, n, u) - np.testing.assert_almost_equal(new_x, x, decimal=8) - np.testing.assert_almost_equal(new_y, y, decimal=8) - np.testing.assert_almost_equal(new_z, z, decimal=8) +def test_ecef_to_enu(random_geoid): + """Closure tests for ECEF to ENU conversion and vice versa.""" + lat, lon, alt = random_geoid + x, y, z = katpoint.lla_to_ecef(lat, lon, alt) + e, n, u = katpoint.ecef_to_enu(lat[0], lon[0], alt[0], x, y, z) + new_x, new_y, new_z = katpoint.enu_to_ecef(lat[0], lon[0], alt[0], e, n, u) + np.testing.assert_almost_equal(new_x, x, decimal=8) + np.testing.assert_almost_equal(new_y, y, decimal=8) + np.testing.assert_almost_equal(new_z, z, decimal=8) -class TestSpherical: - """Closure tests for spherical coordinate transformations.""" - def setup(self): - N = 1000 - np.random.seed(42) - self.az = Angle(2.0 * np.pi * np.random.rand(N), unit=u.rad) - self.el = Angle(0.999 * np.pi * (np.random.rand(N) - 0.5), unit=u.rad) +@pytest.fixture +def random_sphere(): + N = 1000 + np.random.seed(42) + az = Angle(2.0 * np.pi * np.random.rand(N), unit=u.rad) + el = Angle(0.999 * np.pi * (np.random.rand(N) - 0.5), unit=u.rad) + return az, el - def test_azel_to_enu(self): - """Closure tests for (az, el) to ENU conversion and vice versa.""" - e, n, u = katpoint.azel_to_enu(self.az.rad, self.el.rad) - new_az, new_el = katpoint.enu_to_azel(e, n, u) - assert_angles_almost_equal(new_az, self.az.rad, decimal=15) - assert_angles_almost_equal(new_el, self.el.rad, decimal=15) + +def test_azel_to_enu(random_sphere): + """Closure tests for (az, el) to ENU conversion and vice versa.""" + az, el = random_sphere + e, n, u = katpoint.azel_to_enu(az.rad, el.rad) + new_az, new_el = katpoint.enu_to_azel(e, n, u) + assert_angles_almost_equal(new_az, az.rad, decimal=15) + assert_angles_almost_equal(new_el, el.rad, decimal=15) diff --git a/katpoint/test/test_delay.py b/katpoint/test/test_delay.py index 7178b06..1beed09 100644 --- a/katpoint/test/test_delay.py +++ b/katpoint/test/test_delay.py @@ -19,41 +19,38 @@ import json from io import StringIO -import numpy as np import pytest +import numpy as np import astropy.units as u from astropy.coordinates import Angle import katpoint -class TestDelayModel: - """Test antenna delay model.""" - - def test_construct_save_load(self): - """Test construction / save / load of delay model.""" - m = katpoint.DelayModel('1.0, -2.0, -3.0, 4.123, 5.0, 6.0') - m.header['date'] = '2014-01-15' - # An empty file should lead to a BadModelFile exception - cfg_file = StringIO() - with pytest.raises(katpoint.BadModelFile): - m.fromfile(cfg_file) - m.tofile(cfg_file) - cfg_str = cfg_file.getvalue() - cfg_file.close() - # Load the saved config file - cfg_file = StringIO(cfg_str) - m2 = katpoint.DelayModel() - m2.fromfile(cfg_file) - assert m == m2, 'Saving delay model to file and loading it again failed' - params = m.delay_params - m3 = katpoint.DelayModel() - m3.fromdelays(params) - assert m == m3, 'Converting delay model to delay parameters and loading it again failed' - try: - assert hash(m) == hash(m3), 'Delay model hashes not equal' - except TypeError: - pytest.fail('DelayModel object not hashable') +def test_construct_save_load(): + """Test construction / save / load of delay model.""" + m = katpoint.DelayModel('1.0, -2.0, -3.0, 4.123, 5.0, 6.0') + m.header['date'] = '2014-01-15' + # An empty file should lead to a BadModelFile exception + cfg_file = StringIO() + with pytest.raises(katpoint.BadModelFile): + m.fromfile(cfg_file) + m.tofile(cfg_file) + cfg_str = cfg_file.getvalue() + cfg_file.close() + # Load the saved config file + cfg_file = StringIO(cfg_str) + m2 = katpoint.DelayModel() + m2.fromfile(cfg_file) + assert m == m2, 'Saving delay model to file and loading it again failed' + params = m.delay_params + m3 = katpoint.DelayModel() + m3.fromdelays(params) + assert m == m3, 'Converting delay model to delay parameters and loading it again failed' + try: + assert hash(m) == hash(m3), 'Delay model hashes not equal' + except TypeError: + pytest.fail('DelayModel object not hashable') class TestDelayCorrection: diff --git a/katpoint/test/test_model.py b/katpoint/test/test_model.py index 0a571b5..763f801 100644 --- a/katpoint/test/test_model.py +++ b/katpoint/test/test_model.py @@ -23,82 +23,81 @@ import katpoint -class TestModel: - """Test generic model.""" +def params(): + """Generate fresh set of parameters (otherwise models share the same ones).""" + return [katpoint.Parameter('POS_E', 'm', 'East', value=10.0), + katpoint.Parameter('POS_N', 'm', 'North', value=-9.0), + katpoint.Parameter('POS_U', 'm', 'Up', value=3.0), + katpoint.Parameter('NIAO', 'm', 'non-inter', value=0.88), + katpoint.Parameter('CAB_H', '', 'horizontal', value=20.2), + katpoint.Parameter('CAB_V', 'deg', 'vertical', value=20.3)] - def new_params(self): - """Generate fresh set of parameters (otherwise models share the same ones).""" - return [katpoint.Parameter('POS_E', 'm', 'East', value=10.0), - katpoint.Parameter('POS_N', 'm', 'North', value=-9.0), - katpoint.Parameter('POS_U', 'm', 'Up', value=3.0), - katpoint.Parameter('NIAO', 'm', 'non-inter', value=0.88), - katpoint.Parameter('CAB_H', '', 'horizontal', value=20.2), - katpoint.Parameter('CAB_V', 'deg', 'vertical', value=20.3)] - def test_construct_save_load(self): - """Test construction / save / load of generic model.""" - m = katpoint.Model(self.new_params()) - m.header['date'] = '2014-01-15' - # Exercise all string representations for coverage purposes - print('%r %s %r' % (m, m, m.params['POS_E'])) - # An empty file should lead to a BadModelFile exception - cfg_file = StringIO() - with pytest.raises(katpoint.BadModelFile): - m.fromfile(cfg_file) - m.tofile(cfg_file) - cfg_str = cfg_file.getvalue() - cfg_file.close() - # Load the saved config file - cfg_file = StringIO(cfg_str) - m2 = katpoint.Model(self.new_params()) - m2.fromfile(cfg_file) - assert m == m2, 'Saving model to file and loading it again failed' - cfg_file = StringIO(cfg_str) - m2.set(cfg_file) - assert m == m2, 'Saving model to file and loading it again failed' - # Build model from description string - m3 = katpoint.Model(self.new_params()) - m3.fromstring(m.description) - assert m == m3, 'Saving model to string and loading it again failed' - m3.set(m.description) - assert m == m3, 'Saving model to string and loading it again failed' - # Build model from sequence of floats - m4 = katpoint.Model(self.new_params()) - m4.fromlist(m.values()) - assert m == m4, 'Saving model to list and loading it again failed' - m4.set(m.values()) - assert m == m4, 'Saving model to list and loading it again failed' - # Empty model - cfg_file = StringIO('[header]\n[params]\n') - m5 = katpoint.Model(self.new_params()) - m5.fromfile(cfg_file) - print(m5) - assert m != m5, 'Model should not be equal to an empty one' - m6 = katpoint.Model(self.new_params()) - m6.set() - assert m6 == m5, 'Setting empty model failed' - m7 = katpoint.Model(self.new_params()) - m7.set(m) - assert m == m7, 'Construction from model object failed' +def test_construct_save_load(): + """Test construction / save / load of generic model.""" + m = katpoint.Model(params()) + m.header['date'] = '2014-01-15' + # Exercise all string representations for coverage purposes + print('%r %s %r' % (m, m, m.params['POS_E'])) + # An empty file should lead to a BadModelFile exception + cfg_file = StringIO() + with pytest.raises(katpoint.BadModelFile): + m.fromfile(cfg_file) + m.tofile(cfg_file) + cfg_str = cfg_file.getvalue() + cfg_file.close() + # Load the saved config file + cfg_file = StringIO(cfg_str) + m2 = katpoint.Model(params()) + m2.fromfile(cfg_file) + assert m == m2, 'Saving model to file and loading it again failed' + cfg_file = StringIO(cfg_str) + m2.set(cfg_file) + assert m == m2, 'Saving model to file and loading it again failed' + # Build model from description string + m3 = katpoint.Model(params()) + m3.fromstring(m.description) + assert m == m3, 'Saving model to string and loading it again failed' + m3.set(m.description) + assert m == m3, 'Saving model to string and loading it again failed' + # Build model from sequence of floats + m4 = katpoint.Model(params()) + m4.fromlist(m.values()) + assert m == m4, 'Saving model to list and loading it again failed' + m4.set(m.values()) + assert m == m4, 'Saving model to list and loading it again failed' + # Empty model + cfg_file = StringIO('[header]\n[params]\n') + m5 = katpoint.Model(params()) + m5.fromfile(cfg_file) + print(m5) + assert m != m5, 'Model should not be equal to an empty one' + m6 = katpoint.Model(params()) + m6.set() + assert m6 == m5, 'Setting empty model failed' + m7 = katpoint.Model(params()) + m7.set(m) + assert m == m7, 'Construction from model object failed' - class OtherModel(katpoint.Model): - pass - m8 = OtherModel(self.new_params()) - with pytest.raises(katpoint.BadModelFile): - m8.set(m) - try: - assert hash(m) == hash(m4), 'Model hashes not equal' - except TypeError: - pytest.fail('Model object not hashable') + class OtherModel(katpoint.Model): + pass + m8 = OtherModel(params()) + with pytest.raises(katpoint.BadModelFile): + m8.set(m) + try: + assert hash(m) == hash(m4), 'Model hashes not equal' + except TypeError: + pytest.fail('Model object not hashable') - def test_dict_interface(self): - """Test dict interface of generic model.""" - params = self.new_params() - names = [p.name for p in params] - values = [p.value for p in params] - m = katpoint.Model(params) - assert len(m) == 6, 'Unexpected model length' - assert list(m.keys()) == names, 'Parameter names do not match' - assert list(m.values()) == values, 'Parameter values do not match' - m['NIAO'] = 6789.0 - assert m['NIAO'] == 6789.0, 'Parameter setting via dict interface failed' + +def test_dict_interface(): + """Test dict interface of generic model.""" + parameters = params() + names = [p.name for p in parameters] + values = [p.value for p in parameters] + m = katpoint.Model(parameters) + assert len(m) == 6, 'Unexpected model length' + assert list(m.keys()) == names, 'Parameter names do not match' + assert list(m.values()) == values, 'Parameter values do not match' + m['NIAO'] = 6789.0 + assert m['NIAO'] == 6789.0, 'Parameter setting via dict interface failed' diff --git a/katpoint/test/test_pointing.py b/katpoint/test/test_pointing.py index 9c06b02..1c30ab7 100644 --- a/katpoint/test/test_pointing.py +++ b/katpoint/test/test_pointing.py @@ -16,79 +16,87 @@ """Tests for the pointing module.""" -import numpy as np import pytest +import numpy as np import katpoint from .helper import assert_angles_almost_equal -class TestPointingModel: - """Test pointing model.""" +@pytest.fixture +def pointing_grid(): + """Generate a grid of (az, el) values in natural antenna coordinates.""" + az_range = katpoint.deg2rad(np.arange(-185.0, 275.0, 5.0)) + el_range = katpoint.deg2rad(np.arange(0.0, 86.0, 1.0)) + mesh_az, mesh_el = np.meshgrid(az_range, el_range) + az = mesh_az.ravel() + el = mesh_el.ravel() + return az, el + + +@pytest.fixture +def params(): + """Generate random parameters for a pointing model.""" + # Generate random parameter values with this spread + param_stdev = katpoint.deg2rad(20. / 60.) + num_params = len(katpoint.PointingModel()) + np.random.seed(42) + params = param_stdev * np.random.randn(num_params) + return params + + +def test_pointing_model_load_save(params): + """Test construction / load / save of pointing model.""" + pm = katpoint.PointingModel(params) + print('%r %s' % (pm, pm)) + pm2 = katpoint.PointingModel(params[:-1]) + assert pm2.values()[-1] == 0.0, 'Unspecified pointing model params not zeroed' + pm3 = katpoint.PointingModel(np.r_[params, 1.0]) + assert pm3.values()[-1] == params[-1], ( + 'Superfluous pointing model params not handled correctly') + pm4 = katpoint.PointingModel(pm.description) + assert pm4.description == pm.description, ( + 'Saving pointing model to string and loading it again failed') + assert pm4 == pm, 'Pointing models should be equal' + assert pm2 != pm, 'Pointing models should be inequal' + # np.testing.assert_almost_equal(pm4.values(), pm.values(), decimal=6) + for (v4, v) in zip(pm4.values(), pm.values()): + if type(v4) == float: + np.testing.assert_almost_equal(v4, v, decimal=6) + else: + np.testing.assert_almost_equal(v4.rad, v, decimal=6) + try: + assert hash(pm4) == hash(pm), 'Pointing model hashes not equal' + except TypeError: + pytest.fail('PointingModel object not hashable') - def setup(self): - np.random.seed(42) - az_range = katpoint.deg2rad(np.arange(-185.0, 275.0, 5.0)) - el_range = katpoint.deg2rad(np.arange(0.0, 86.0, 1.0)) - mesh_az, mesh_el = np.meshgrid(az_range, el_range) - self.az = mesh_az.ravel() - self.el = mesh_el.ravel() - # Generate random parameter values with this spread - self.param_stdev = katpoint.deg2rad(20. / 60.) - self.num_params = len(katpoint.PointingModel()) - def test_pointing_model_load_save(self): - """Test construction / load / save of pointing model.""" - params = katpoint.deg2rad(np.random.randn(self.num_params + 1)) - pm = katpoint.PointingModel(params[:-1]) - print('%r %s' % (pm, pm)) - pm2 = katpoint.PointingModel(params[:-2]) - assert pm2.values()[-1] == 0.0, 'Unspecified pointing model params not zeroed' - pm3 = katpoint.PointingModel(params) - assert pm3.values()[-1] == params[-2], ( - 'Superfluous pointing model params not handled correctly') - pm4 = katpoint.PointingModel(pm.description) - assert pm4.description == pm.description, ( - 'Saving pointing model to string and loading it again failed') - assert pm4 == pm, 'Pointing models should be equal' - assert pm2 != pm, 'Pointing models should be inequal' - # np.testing.assert_almost_equal(pm4.values(), pm.values(), decimal=6) - for (v4, v) in zip(pm4.values(), pm.values()): - if type(v4) == float: - np.testing.assert_almost_equal(v4, v, decimal=6) - else: - np.testing.assert_almost_equal(v4.rad, v, decimal=6) - try: - assert hash(pm4) == hash(pm), 'Pointing model hashes not equal' - except TypeError: - pytest.fail('PointingModel object not hashable') +def test_pointing_closure(params, pointing_grid): + """Test closure between pointing correction and its reverse operation.""" + pm = katpoint.PointingModel(params) + # Test closure on (az, el) grid + grid_az, grid_el = pointing_grid + pointed_az, pointed_el = pm.apply(grid_az, grid_el) + az, el = pm.reverse(pointed_az, pointed_el) + assert_angles_almost_equal(az, grid_az, decimal=6, + err_msg='Azimuth closure error for params=%s' % (params,)) + assert_angles_almost_equal(el, grid_el, decimal=7, + err_msg='Elevation closure error for params=%s' % (params,)) - def test_pointing_closure(self): - """Test closure between pointing correction and its reverse operation.""" - # Generate random pointing model - params = self.param_stdev * np.random.randn(self.num_params) - pm = katpoint.PointingModel(params) - # Test closure on (az, el) grid - pointed_az, pointed_el = pm.apply(self.az, self.el) - az, el = pm.reverse(pointed_az, pointed_el) - assert_angles_almost_equal(az, self.az, decimal=6, - err_msg='Azimuth closure error for params=%s' % (params,)) - assert_angles_almost_equal(el, self.el, decimal=7, - err_msg='Elevation closure error for params=%s' % (params,)) - def test_pointing_fit(self): - """Test fitting of pointing model.""" - # Generate random pointing model and corresponding offsets on (az, el) grid - params = self.param_stdev * np.random.randn(self.num_params) - params[1] = params[9] = 0.0 - pm = katpoint.PointingModel(params.copy()) - delta_az, delta_el = pm.offset(self.az, self.el) - enabled_params = (np.arange(self.num_params) + 1).tolist() - # Comment out these removes, thereby testing more code paths in PointingModel - # enabled_params.remove(2) - # enabled_params.remove(10) - fitted_params, sigma_params = pm.fit(self.az, self.el, delta_az, delta_el, enabled_params=[]) - np.testing.assert_equal(fitted_params, np.zeros(self.num_params)) - fitted_params, sigma_params = pm.fit(self.az, self.el, delta_az, delta_el, enabled_params=enabled_params) - np.testing.assert_almost_equal(fitted_params, params, decimal=9) +def test_pointing_fit(params, pointing_grid): + """Test fitting of pointing model.""" + # Generate random pointing model and corresponding offsets on (az, el) grid + params[1] = params[9] = 0.0 + pm = katpoint.PointingModel(params.copy()) + grid_az, grid_el = pointing_grid + delta_az, delta_el = pm.offset(grid_az, grid_el) + enabled_params = (np.arange(len(pm)) + 1).tolist() + # Comment out these removes, thereby testing more code paths in PointingModel + # enabled_params.remove(2) + # enabled_params.remove(10) + fitted_params, sigma_params = pm.fit(grid_az, grid_el, delta_az, delta_el, enabled_params=[]) + np.testing.assert_equal(fitted_params, np.zeros(len(pm))) + fitted_params, sigma_params = pm.fit(grid_az, grid_el, delta_az, delta_el, enabled_params=enabled_params) + np.testing.assert_almost_equal(fitted_params, params, decimal=9) diff --git a/katpoint/test/test_refraction.py b/katpoint/test/test_refraction.py index 2e10315..adc8097 100644 --- a/katpoint/test/test_refraction.py +++ b/katpoint/test/test_refraction.py @@ -24,45 +24,42 @@ from .helper import assert_angles_almost_equal -class TestRefractionCorrection: - """Test refraction correction.""" +def test_refraction_basic(): + """Test basic refraction correction properties.""" + rc = katpoint.RefractionCorrection() + print(repr(rc)) + with pytest.raises(ValueError): + katpoint.RefractionCorrection('unknown') + rc2 = katpoint.RefractionCorrection() + assert rc == rc2, 'Refraction models should be equal' + try: + assert hash(rc) == hash(rc2), 'Refraction model hashes should be equal' + except TypeError: + pytest.fail('RefractionCorrection object not hashable') - def setup(self): - self.rc = katpoint.RefractionCorrection() - self.el = katpoint.deg2rad(np.arange(0.0, 90.1, 0.1)) - def test_refraction_basic(self): - """Test basic refraction correction properties.""" - print(repr(self.rc)) - with pytest.raises(ValueError): - katpoint.RefractionCorrection('unknown') - rc2 = katpoint.RefractionCorrection() - assert self.rc == rc2, 'Refraction models should be equal' - try: - assert hash(self.rc) == hash(rc2), 'Refraction model hashes should be equal' - except TypeError: - pytest.fail('RefractionCorrection object not hashable') - - def test_refraction_closure(self): - """Test closure between refraction correction and its reverse operation.""" - np.random.seed(42) - # Generate random meteorological data (hopefully sensible) - first only a single weather measurement - temp = -10. + 50. * np.random.rand() - pressure = 900. + 200. * np.random.rand() - humidity = 5. + 90. * np.random.rand() - # Test closure on el grid - refracted_el = self.rc.apply(self.el, temp, pressure, humidity) - reversed_el = self.rc.reverse(refracted_el, temp, pressure, humidity) - assert_angles_almost_equal(reversed_el, self.el, decimal=7, - err_msg='Elevation closure error for temp=%f, pressure=%f, humidity=%f' % - (temp, pressure, humidity)) - # Generate random meteorological data, now one weather measurement per elevation value - temp = -10. + 50. * np.random.rand(len(self.el)) - pressure = 900. + 200. * np.random.rand(len(self.el)) - humidity = 5. + 90. * np.random.rand(len(self.el)) - # Test closure on el grid - refracted_el = self.rc.apply(self.el, temp, pressure, humidity) - reversed_el = self.rc.reverse(refracted_el, temp, pressure, humidity) - assert_angles_almost_equal(reversed_el, self.el, decimal=7, - err_msg='Elevation closure error for temp=%s, pressure=%s, humidity=%s' % - (temp, pressure, humidity)) +def test_refraction_closure(): + """Test closure between refraction correction and its reverse operation.""" + rc = katpoint.RefractionCorrection() + el = katpoint.deg2rad(np.arange(0.0, 90.1, 0.1)) + np.random.seed(42) + # Generate random meteorological data (a single measurement, hopefully sensible) + temp = -10. + 50. * np.random.rand() + pressure = 900. + 200. * np.random.rand() + humidity = 5. + 90. * np.random.rand() + # Test closure on el grid + refracted_el = rc.apply(el, temp, pressure, humidity) + reversed_el = rc.reverse(refracted_el, temp, pressure, humidity) + assert_angles_almost_equal(reversed_el, el, decimal=7, + err_msg='Elevation closure error for temp=%f, pressure=%f, humidity=%f' % + (temp, pressure, humidity)) + # Generate random meteorological data, now one weather measurement per elevation value + temp = -10. + 50. * np.random.rand(len(el)) + pressure = 900. + 200. * np.random.rand(len(el)) + humidity = 5. + 90. * np.random.rand(len(el)) + # Test closure on el grid + refracted_el = rc.apply(el, temp, pressure, humidity) + reversed_el = rc.reverse(refracted_el, temp, pressure, humidity) + assert_angles_almost_equal(reversed_el, el, decimal=7, + err_msg='Elevation closure error for temp=%s, pressure=%s, humidity=%s' % + (temp, pressure, humidity)) From 020adefb561ad2d52a998d5933101965aa1ed2e5 Mon Sep 17 00:00:00 2001 From: Ludwig Schwardt Date: Tue, 14 Jul 2020 16:43:30 +0200 Subject: [PATCH 028/122] Set the random seed in one place for all tests The autouse fixture is automatically called before each test, ensuring that the RNG is in a known state. This removes the schlep of remembering to set it in the tests that need it. --- katpoint/test/conftest.py | 23 +++++++++++++++++++++++ katpoint/test/test_conversion.py | 2 -- katpoint/test/test_pointing.py | 1 - katpoint/test/test_projection.py | 3 --- katpoint/test/test_refraction.py | 1 - 5 files changed, 23 insertions(+), 7 deletions(-) create mode 100644 katpoint/test/conftest.py diff --git a/katpoint/test/conftest.py b/katpoint/test/conftest.py new file mode 100644 index 0000000..df01fe7 --- /dev/null +++ b/katpoint/test/conftest.py @@ -0,0 +1,23 @@ +################################################################################ +# Copyright (c) 2009-2020, National Research Foundation (SARAO) +# +# Licensed under the BSD 3-Clause License (the "License"); you may not use +# this file except in compliance with the License. You may obtain a copy +# of the License at +# +# https://opensource.org/licenses/BSD-3-Clause +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +################################################################################ + +import pytest +import numpy as np + + +@pytest.fixture(autouse=True) +def fix_random_seed(): + np.random.seed(42) diff --git a/katpoint/test/test_conversion.py b/katpoint/test/test_conversion.py index ad9b050..3af364b 100644 --- a/katpoint/test/test_conversion.py +++ b/katpoint/test/test_conversion.py @@ -29,7 +29,6 @@ @pytest.fixture def random_geoid(): N = 1000 - np.random.seed(42) lat = 0.999 * np.pi * (np.random.rand(N) - 0.5) lon = 2.0 * np.pi * np.random.rand(N) alt = 1000.0 * np.random.randn(N) @@ -69,7 +68,6 @@ def test_ecef_to_enu(random_geoid): @pytest.fixture def random_sphere(): N = 1000 - np.random.seed(42) az = Angle(2.0 * np.pi * np.random.rand(N), unit=u.rad) el = Angle(0.999 * np.pi * (np.random.rand(N) - 0.5), unit=u.rad) return az, el diff --git a/katpoint/test/test_pointing.py b/katpoint/test/test_pointing.py index 1c30ab7..9f2d4d6 100644 --- a/katpoint/test/test_pointing.py +++ b/katpoint/test/test_pointing.py @@ -41,7 +41,6 @@ def params(): # Generate random parameter values with this spread param_stdev = katpoint.deg2rad(20. / 60.) num_params = len(katpoint.PointingModel()) - np.random.seed(42) params = param_stdev * np.random.randn(num_params) return params diff --git a/katpoint/test/test_projection.py b/katpoint/test/test_projection.py index cfe7aad..5461e96 100644 --- a/katpoint/test/test_projection.py +++ b/katpoint/test/test_projection.py @@ -121,7 +121,6 @@ def test_random_closure(projection, decimal, N=100): """Do random projections and check closure.""" plane_to_sphere = katpoint.plane_to_sphere[projection] sphere_to_plane = katpoint.sphere_to_plane[projection] - np.random.seed(hash(projection) & (2 ** 32 - 1)) az0, el0, x, y = generate_data[projection](N) az, el = plane_to_sphere(az0, el0, x, y) xx, yy = sphere_to_plane(az0, el0, az, el) @@ -141,7 +140,6 @@ def test_aips_compatibility(projection, aips_code, decimal, N=100): """Compare with original AIPS routine (if available).""" plane_to_sphere = katpoint.plane_to_sphere[projection] sphere_to_plane = katpoint.sphere_to_plane[projection] - np.random.seed(hash(projection) & (2 ** 32 - 1)) az0, el0, x, y = generate_data[projection](N) if projection == 'TAN': # AIPS TAN only deprojects (x, y) coordinates within unit circle @@ -333,7 +331,6 @@ def plane_to_sphere_original_ssn(target_az, target_el, ll, mm): def test_vs_original_ssn(decimal=10, N=100): """SSN projection: compare against Mattieu's original version.""" plane_to_sphere = katpoint.plane_to_sphere['SSN'] - np.random.seed(hash('SSN') & (2 ** 32 - 1)) az0, el0, x, y = generate_data['SSN'](N) az, el = plane_to_sphere(az0, el0, x, y) ll, mm = sphere_to_plane_original_ssn(az0, el0, az, el) diff --git a/katpoint/test/test_refraction.py b/katpoint/test/test_refraction.py index adc8097..958632a 100644 --- a/katpoint/test/test_refraction.py +++ b/katpoint/test/test_refraction.py @@ -42,7 +42,6 @@ def test_refraction_closure(): """Test closure between refraction correction and its reverse operation.""" rc = katpoint.RefractionCorrection() el = katpoint.deg2rad(np.arange(0.0, 90.1, 0.1)) - np.random.seed(42) # Generate random meteorological data (a single measurement, hopefully sensible) temp = -10. + 50. * np.random.rand() pressure = 900. + 200. * np.random.rand() From 2d3576d76a14ff33b61f919bf4d60852a9ce0907 Mon Sep 17 00:00:00 2001 From: Ludwig Schwardt Date: Tue, 14 Jul 2020 22:24:17 +0200 Subject: [PATCH 029/122] Compress projection tests further Rationalise the corner cases by introducing a sphere->plane->sphere function which checks the forward and reverse projections at the same time. Make a parametrized test case for offsets around the origin and another for offsets around the pole. In the latter case the SIN projection needs to back off slightly from the maximum offset of 90 degrees to avoid hitting the domain boundary. Setting this backoff to 1e-12 instead of 1e-8 radians also allows the plane->sphere projection to pass. --- katpoint/test/test_projection.py | 99 ++++++++++---------------------- 1 file changed, 29 insertions(+), 70 deletions(-) diff --git a/katpoint/test/test_projection.py b/katpoint/test/test_projection.py index 5461e96..b02c132 100644 --- a/katpoint/test/test_projection.py +++ b/katpoint/test/test_projection.py @@ -167,24 +167,8 @@ def test_aips_compatibility(projection, aips_code, decimal, N=100): "projection, sphere, plane", [ # Reference point at pole on sphere - ('SIN', (0.0, PI/2, 0.0, 0.0), [0.0, -1.0]), - ('SIN', (0.0, PI/2, PI, 1e-8), [0.0, 1.0]), - ('SIN', (0.0, PI/2, PI/2, 0.0), [1.0, 0.0]), - ('SIN', (0.0, PI/2, -PI/2, 0.0), [-1.0, 0.0]), - ('TAN', (0.0, PI/2, 0.0, PI/4), [0.0, -1.0]), - ('TAN', (0.0, PI/2, PI, PI/4), [0.0, 1.0]), - ('TAN', (0.0, PI/2, PI/2, PI/4), [1.0, 0.0]), - ('TAN', (0.0, PI/2, -PI/2, PI/4), [-1.0, 0.0]), - ('ARC', (0.0, PI/2, 0.0, 0.0), [0.0, -PI/2]), - ('ARC', (0.0, PI/2, PI, 0.0), [0.0, PI/2]), - ('ARC', (0.0, PI/2, PI/2, 0.0), [PI/2, 0.0]), - ('ARC', (0.0, PI/2, -PI/2, 0.0), [-PI/2, 0.0]), - ('STG', (0.0, PI/2, 0.0, 0.0), [0.0, -2.0]), - ('STG', (0.0, PI/2, PI, 0.0), [0.0, 2.0]), - ('STG', (0.0, PI/2, PI/2, 0.0), [2.0, 0.0]), - ('STG', (0.0, PI/2, -PI/2, 0.0), [-2.0, 0.0]), ('SSN', (0.0, PI/2, 0.0, 0.0), [0.0, 1.0]), - ('SSN', (0.0, PI/2, PI, 1e-8), [0.0, 1.0]), + ('SSN', (0.0, PI/2, PI, 1e-12), [0.0, 1.0]), ('SSN', (0.0, PI/2, PI/2, 0.0), [0.0, 1.0]), ('SSN', (0.0, PI/2, -PI/2, 0.0), [0.0, 1.0]), ] @@ -211,26 +195,6 @@ def test_sphere_to_plane_outside_domain(projection): test_sphere_to_plane_invalid(projection, (0.0, 0.0, 0.0, PI)) -@pytest.mark.parametrize("projection", ['SIN', 'TAN', 'ARC', 'STG', 'SSN']) -def test_sphere_to_plane_origin(projection): - """Test origin (sphere -> plane).""" - test_sphere_to_plane(projection, (0.0, 0.0, 0.0, 0.0), [0.0, 0.0]) - - -@pytest.mark.parametrize("projection, offset_s, offset_p", - # Points 45 degrees from reference point on sphere - [('TAN', PI/4, 1.0), - # Points 90 degrees from reference point on sphere - ('SIN', PI/2, 1.0), ('ARC', PI/2, PI/2), - ('STG', PI/2, 2.0), ('SSN', PI/2, -1.0)]) -def test_sphere_to_plane_cross(projection, offset_s, offset_p): - """Test four-point cross along axes, centred on origin (sphere -> plane).""" - test_sphere_to_plane(projection, (0.0, 0.0, +offset_s, 0.0), [+offset_p, 0.0]) - test_sphere_to_plane(projection, (0.0, 0.0, -offset_s, 0.0), [-offset_p, 0.0]) - test_sphere_to_plane(projection, (0.0, 0.0, 0.0, +offset_s), [0.0, +offset_p]) - test_sphere_to_plane(projection, (0.0, 0.0, 0.0, -offset_s), [0.0, -offset_p]) - - def test_sphere_to_plane_special(): """Test special corner cases (sphere -> plane).""" sphere_to_plane = katpoint.sphere_to_plane['ARC'] @@ -248,10 +212,6 @@ def test_sphere_to_plane_special(): ('ARC', (0.0, 0.0, 0.0, PI), [PI, 0.0]), ('ARC', (0.0, 0.0, 0.0, -PI), [PI, 0.0]), # Reference point at pole on sphere - ('TAN', (0.0, -PI/2, 1.0, 0.0), [PI/2, -PI/4]), - ('TAN', (0.0, -PI/2, -1.0, 0.0), [-PI/2, -PI/4]), - ('TAN', (0.0, -PI/2, 0.0, 1.0), [0.0, -PI/4]), - ('TAN', (0.0, -PI/2, 0.0, -1.0), [PI, -PI/4]), ('SSN', (0.0, PI/2, 0.0, 1.0), [0.0, 0.0]), ('SSN', (0.0, -PI/2, 0.0, -1.0), [0.0, 0.0]), # Test valid (x, y) domain @@ -280,35 +240,34 @@ def test_plane_to_sphere_outside_domain(projection, offset_p): plane_to_sphere_invalid(projection, (0.0, 0.0, 0.0, offset_p)) -@pytest.mark.parametrize("projection", ['SIN', 'TAN', 'ARC', 'STG', 'SSN']) -def test_plane_to_sphere_origin(projection): - """Test origin (plane -> sphere).""" - test_plane_to_sphere(projection, (0.0, 0.0, 0.0, 0.0), [0.0, 0.0]) - - -@pytest.mark.parametrize("projection, offset_p, offset_s", - # Points on unit circle in plane - [('SIN', 1.0, PI/2), ('TAN', 1.0, PI/4), - ('ARC', 1.0, 1.0), ('SSN', 1.0, -PI/2), - # Points on circle of radius 2.0 in plane - ('STG', 2.0, PI/2)]) -def test_plane_to_sphere_cross(projection, offset_p, offset_s): - """Test four-point cross along axes, centred on origin (plane -> sphere).""" - test_plane_to_sphere(projection, (0.0, 0.0, +offset_p, 0.0), [+offset_s, 0.0]) - test_plane_to_sphere(projection, (0.0, 0.0, -offset_p, 0.0), [-offset_s, 0.0]) - test_plane_to_sphere(projection, (0.0, 0.0, 0.0, +offset_p), [0.0, +offset_s]) - test_plane_to_sphere(projection, (0.0, 0.0, 0.0, -offset_p), [0.0, -offset_s]) - - -@pytest.mark.parametrize("projection, offset_p, offset_s", - # Reference point at pole on sphere - [('SIN', 1.0, PI/2), ('ARC', PI/2, PI/2), ('STG', 2.0, PI/2)]) -def test_plane_to_sphere_cross_pole(projection, offset_p, offset_s): - """Test four-point cross along axes, centred on pole of sphere (plane -> sphere).""" - test_plane_to_sphere(projection, (0.0, -PI/2, +offset_p, 0.0), [+offset_s, 0.0]) - test_plane_to_sphere(projection, (0.0, -PI/2, -offset_p, 0.0), [-offset_s, 0.0]) - test_plane_to_sphere(projection, (0.0, -PI/2, 0.0, +offset_p), [0.0, 0.0]) - test_plane_to_sphere(projection, (0.0, -PI/2, 0.0, -offset_p), [2 * offset_s, 0.0]) +def sphere_to_plane_to_sphere(projection, reference, sphere, plane): + """Project from sphere to plane and back again and check results on both legs.""" + test_sphere_to_plane(projection, tuple(reference) + tuple(sphere), plane) + test_plane_to_sphere(projection, tuple(reference) + tuple(plane), sphere) + + +@pytest.mark.parametrize("projection, offset_s, offset_p", + [('SIN', PI/2, 1.0), ('TAN', PI/4, 1.0), ('ARC', PI/2, PI/2), + ('STG', PI/2, 2.0), ('SSN', PI/2, -1.0)]) +def test_sphere_to_plane_to_sphere_origin(projection, offset_s, offset_p): + """Test five-point cross along axes, centred on origin (sphere -> plane -> sphere).""" + sphere_to_plane_to_sphere(projection, (0.0, 0.0), (0.0, 0.0), [0.0, 0.0]) + sphere_to_plane_to_sphere(projection, (0.0, 0.0), (+offset_s, 0.0), [+offset_p, 0.0]) + sphere_to_plane_to_sphere(projection, (0.0, 0.0), (-offset_s, 0.0), [-offset_p, 0.0]) + sphere_to_plane_to_sphere(projection, (0.0, 0.0), (0.0, +offset_s), [0.0, +offset_p]) + sphere_to_plane_to_sphere(projection, (0.0, 0.0), (0.0, -offset_s), [0.0, -offset_p]) + + +@pytest.mark.parametrize("projection, offset_s, offset_p", + [('SIN', PI/2 - 1e-12, 1.0), ('TAN', PI/4, 1.0), + ('ARC', PI/2, PI/2), ('STG', PI/2, 2.0)]) +def test_sphere_to_plane_to_sphere_pole(projection, offset_s, offset_p): + """Test four-point cross along axes, centred on pole (sphere -> plane -> sphere).""" + el = PI/2 - offset_s + sphere_to_plane_to_sphere(projection, (0.0, PI/2), (+PI/2, el), [+offset_p, 0.0]) + sphere_to_plane_to_sphere(projection, (0.0, PI/2), (-PI/2, el), [-offset_p, 0.0]) + sphere_to_plane_to_sphere(projection, (0.0, PI/2), (PI, el), [0.0, +offset_p]) + sphere_to_plane_to_sphere(projection, (0.0, PI/2), (0.0, el), [0.0, -offset_p]) def sphere_to_plane_original_ssn(target_az, target_el, scan_az, scan_el): From c9fd9627aa5dc20da7e3e9b6b1d769bc9382ff4c Mon Sep 17 00:00:00 2001 From: Ludwig Schwardt Date: Wed, 15 Jul 2020 09:50:38 +0200 Subject: [PATCH 030/122] Turn duplicate assignments into module constants The filter_catalogue_parts fixture is also a bit of a sham, so undo it. --- katpoint/test/test_catalogue.py | 52 ++++++++++++++------------------- 1 file changed, 22 insertions(+), 30 deletions(-) diff --git a/katpoint/test/test_catalogue.py b/katpoint/test/test_catalogue.py index 65ecbbf..94621d0 100644 --- a/katpoint/test/test_catalogue.py +++ b/katpoint/test/test_catalogue.py @@ -26,6 +26,9 @@ # Use the current year in TLE epochs to avoid pyephem crash due to expired TLEs YY = time.localtime().tm_year % 100 +FLUX_TARGET = katpoint.Target('flux, radec, 0.0, 0.0, (1.0 2.0 2.0 0.0 0.0)') +ANTENNA = katpoint.Antenna('XDM, -25:53:23.05075, 27:41:03.36453, 1406.1086, 15.0') +TIMESTAMP = time.mktime(time.strptime('2009/06/14 12:34:56', '%Y/%m/%d %H:%M:%S')) def test_catalogue_basic(): @@ -79,8 +82,7 @@ def test_catalogue_same_name(): def test_construct_catalogue(): """Test construction of catalogues.""" - antenna = katpoint.Antenna('XDM, -25:53:23.05075, 27:41:03.36453, 1406.1086, 15.0') - cat = katpoint.Catalogue(add_specials=True, add_stars=True, antenna=antenna) + cat = katpoint.Catalogue(add_specials=True, add_stars=True, antenna=ANTENNA) num_targets_original = len(cat) assert num_targets_original == len(katpoint.specials) + 1 + len(katpoint.stars.stars) # Add target already in catalogue - no action @@ -140,54 +142,44 @@ def test_skip_empty(): assert len(cat) == 0 -@pytest.fixture -def filter_catalogue_parts(): - flux_target = katpoint.Target('flux, radec, 0.0, 0.0, (1.0 2.0 2.0 0.0 0.0)') - antenna = katpoint.Antenna('XDM, -25:53:23.05075, 27:41:03.36453, 1406.1086, 15.0') - timestamp = time.mktime(time.strptime('2009/06/14 12:34:56', '%Y/%m/%d %H:%M:%S')) - return antenna, timestamp, flux_target - - -def test_filter_catalogue(filter_catalogue_parts): +def test_filter_catalogue(): """Test filtering of catalogues.""" - antenna, timestamp, flux_target = filter_catalogue_parts cat = katpoint.Catalogue(add_specials=True, add_stars=True) cat = cat.filter(tags=['special', '~radec']) assert len(cat.targets) == len(katpoint.specials), 'Number of targets incorrect' - cat.add(flux_target) + cat.add(FLUX_TARGET) cat2 = cat.filter(flux_limit_Jy=50.0, flux_freq_MHz=1.5) assert len(cat2.targets) == 1, 'Number of targets with sufficient flux should be 1' assert cat != cat2, 'Catalogues should be inequal' - cat3 = cat.filter(az_limit_deg=[0, 180], timestamp=timestamp, antenna=antenna) + cat3 = cat.filter(az_limit_deg=[0, 180], timestamp=TIMESTAMP, antenna=ANTENNA) assert len(cat3.targets) == 1, 'Number of targets rising should be 1' - cat4 = cat.filter(az_limit_deg=[180, 0], timestamp=timestamp, antenna=antenna) + cat4 = cat.filter(az_limit_deg=[180, 0], timestamp=TIMESTAMP, antenna=ANTENNA) assert len(cat4.targets) == 9, 'Number of targets setting should be 9' cat.add(katpoint.Target('Zenith, azel, 0, 90')) - cat5 = cat.filter(el_limit_deg=85, timestamp=timestamp, antenna=antenna) + cat5 = cat.filter(el_limit_deg=85, timestamp=TIMESTAMP, antenna=ANTENNA) assert len(cat5.targets) == 1, 'Number of targets close to zenith should be 1' sun = katpoint.Target('Sun, special') cat6 = cat.filter(dist_limit_deg=[0.0, 1.0], proximity_targets=sun, - timestamp=timestamp, antenna=antenna) + timestamp=TIMESTAMP, antenna=ANTENNA) assert len(cat6.targets) == 1, 'Number of targets close to Sun should be 1' -def test_sort_catalogue(filter_catalogue_parts): +def test_sort_catalogue(): """Test sorting of catalogues.""" - antenna, timestamp, flux_target = filter_catalogue_parts cat = katpoint.Catalogue(add_specials=True, add_stars=True) assert len(cat.targets) == len(katpoint.specials) + 1 + len(katpoint.stars.stars) cat1 = cat.sort(key='name') assert cat1 == cat, 'Catalogue equality failed' assert cat1.targets[0].name == 'Acamar', 'Sorting on name failed' - cat2 = cat.sort(key='ra', timestamp=timestamp, antenna=antenna) + cat2 = cat.sort(key='ra', timestamp=TIMESTAMP, antenna=ANTENNA) assert cat2.targets[0].name == 'Alpheratz', 'Sorting on ra failed' - cat3 = cat.sort(key='dec', timestamp=timestamp, antenna=antenna) + cat3 = cat.sort(key='dec', timestamp=TIMESTAMP, antenna=ANTENNA) assert cat3.targets[0].name == 'Miaplacidus', 'Sorting on dec failed' - cat4 = cat.sort(key='az', timestamp=timestamp, antenna=antenna, ascending=False) + cat4 = cat.sort(key='az', timestamp=TIMESTAMP, antenna=ANTENNA, ascending=False) assert cat4.targets[0].name == 'Polaris', 'Sorting on az failed' # az: 359:25:07.3 - cat5 = cat.sort(key='el', timestamp=timestamp, antenna=antenna) + cat5 = cat.sort(key='el', timestamp=TIMESTAMP, antenna=ANTENNA) assert cat5.targets[-1].name == 'Zenith', 'Sorting on el failed' # el: 90:00:00.0 - cat.add(flux_target) + cat.add(FLUX_TARGET) cat6 = cat.sort(key='flux', ascending=False, flux_freq_MHz=1.5) assert 'flux' in (cat6.targets[0].name, cat6.targets[-1].name), ( 'Flux target should be at start or end of catalogue after sorting') @@ -195,15 +187,15 @@ def test_sort_catalogue(filter_catalogue_parts): (cat6.targets[-1].flux_density(1.5) == 100.0)), 'Sorting on flux failed' -def test_visibility_list(filter_catalogue_parts): +def test_visibility_list(): """Test output of visibility list.""" - antenna, timestamp, flux_target = filter_catalogue_parts antenna2 = katpoint.Antenna('XDM2, -25:53:23.05075, 27:41:03.36453, ' '1406.1086, 15.0, 100.0 0.0 0.0') cat = katpoint.Catalogue(add_specials=True, add_stars=True) - cat.add(flux_target) + cat.add(FLUX_TARGET) cat.remove('Zenith') - cat.visibility_list(timestamp=timestamp, antenna=antenna, flux_freq_MHz=1.5, antenna2=antenna2) - cat.antenna = antenna + cat.visibility_list(timestamp=TIMESTAMP, antenna=ANTENNA, + flux_freq_MHz=1.5, antenna2=antenna2) + cat.antenna = ANTENNA cat.flux_freq_MHz = 1.5 - cat.visibility_list(timestamp=timestamp) + cat.visibility_list(timestamp=TIMESTAMP) From 0895978b24b909366f41d0d1d59f3edb1aee2f7c Mon Sep 17 00:00:00 2001 From: Ludwig Schwardt Date: Tue, 14 Jul 2020 00:16:46 +0200 Subject: [PATCH 031/122] Turn Timestamp into a shim for Astropy Time It would have been nice to ditch `Timestamp` completely or at least make it a subclass of `astropy.time.Time`. There are some issues with this though, not the least of which is the fact that `Timestamp` does arithmetic in seconds while `Time` operates in days. The following changes are made to `Timestamp`: - The `Timestamp` object contains an Astropy `Time` object exposed via the `time` attribute which does all the work under the hood. - The `time` attribute replaces the `to_ephem_date` method as the gateway to the underlying functionality. - The object can be constructed from a `Time` object or another `Timestamp` (a shallow copy). - Construction from a string is slightly more stringent, disallowing strings that contain a year, a year and month, or a date and hour only. These unnatural prefixes are accepted by PyEphem but not Astropy. - The object can be constructed from an array of floats, strings or `Time`s (not covered by unit tests yet). - The comparison operators are fleshed out to handle array comparisons too (`total_ordering` did not like that...). - The `secs` attribute and `to_mjd` method work as before. Remaining problems are the `local` timezone-aware string method (which happens to clash with the Astropy namespace too) and a lack of array testing. This addresses JIRA ticket SPAZA-153. --- katpoint/antenna.py | 2 +- katpoint/catalogue.py | 9 +- katpoint/target.py | 6 +- katpoint/test/test_timestamp.py | 9 +- katpoint/timestamp.py | 206 ++++++++++++++------------------ 5 files changed, 102 insertions(+), 130 deletions(-) diff --git a/katpoint/antenna.py b/katpoint/antenna.py index f0d5d10..6943c5f 100644 --- a/katpoint/antenna.py +++ b/katpoint/antenna.py @@ -338,7 +338,7 @@ def local_sidereal_time(self, timestamp=None): """ def _scalar_local_sidereal_time(t): """Calculate local sidereal time at a single time instant.""" - time = Time(Timestamp(t).to_ephem_date(), location=self.earth_location) + time = Time(Timestamp(t).time, location=self.earth_location) return time.sidereal_time('apparent') if is_iterable(timestamp): diff --git a/katpoint/catalogue.py b/katpoint/catalogue.py index 9fde43f..937d7e2 100644 --- a/katpoint/catalogue.py +++ b/katpoint/catalogue.py @@ -20,6 +20,7 @@ from collections import defaultdict import numpy as np +from astropy.time import Time from .target import Target from .timestamp import Timestamp @@ -505,12 +506,14 @@ def add_tle(self, lines, tags=None): name = target.split('\n')[0][4:].strip() epoch_year, epoch_day = float(target.split('\n')[1][19:21]), float(target.split('\n')[1][21:33]) epoch_year = epoch_year + 1900 if epoch_year >= 57 else epoch_year + 2000 - epoch = Timestamp('%d' % (epoch_year,)) + (epoch_day - 1.0) * 24. * 3600. + frac_epoch_day, int_epoch_day = np.modf(epoch_day) + yday_date = '{:4d}:{:03d}'.format(int(epoch_year), int(int_epoch_day)) + epoch = Time(yday_date, format='yday') + frac_epoch_day revs_per_day = float(target.split('\n')[2][53:64]) # Use orbital period to distinguish near-earth and deep-space objects (which have different accuracies) orbital_period_mins = 24. / revs_per_day * 60. - now = Timestamp() - epoch_diff_days = np.abs(now - epoch) / 3600. / 24. + now = Time.now() + epoch_diff_days = np.abs(now - epoch).jd direction = 'past' if epoch < now else 'future' # Near-earth models should be good for about a week (conservative estimate) if orbital_period_mins < 225 and epoch_diff_days > 7: diff --git a/katpoint/target.py b/katpoint/target.py index d6bb551..6d40d04 100644 --- a/katpoint/target.py +++ b/katpoint/target.py @@ -337,7 +337,7 @@ def azel(self, timestamp=None, antenna=None): def _scalar_azel(t): """Calculate (az, el) coordinates for a single time instant.""" self.body.compute(antenna.earth_location, - Timestamp(t).to_ephem_date(), antenna.pressure) + Timestamp(t).time, antenna.pressure) return self.body.altaz if is_iterable(timestamp): azel = np.array([_scalar_azel(t) for t in timestamp], dtype=object) @@ -378,7 +378,7 @@ def apparent_radec(self, timestamp=None, antenna=None): def _scalar_radec(t): """Calculate (ra, dec) coordinates for a single time instant.""" - date = Timestamp(t).to_ephem_date() + date = Timestamp(t).time self.body.compute(antenna.earth_location, date, antenna.pressure) return self.body.radec if is_iterable(timestamp): @@ -423,7 +423,7 @@ def astrometric_radec(self, timestamp=None, antenna=None): def _scalar_radec(t): """Calculate (ra, dec) coordinates for a single time instant.""" - date = Timestamp(t).to_ephem_date() + date = Timestamp(t).time self.body.compute(antenna.earth_location, date, antenna.pressure) return self.body.a_radec diff --git a/katpoint/test/test_timestamp.py b/katpoint/test/test_timestamp.py index 1691c2e..09c51ff 100644 --- a/katpoint/test/test_timestamp.py +++ b/katpoint/test/test_timestamp.py @@ -34,18 +34,12 @@ def setup(self): ('2009-07-21 02:52:12.000', '2009-07-21 02:52:12'), ('2009-07-21 02:52:12', '2009-07-21 02:52:12'), ('2009-07-21 02:52', '2009-07-21 02:52:00'), - ('2009-07-21 02', '2009-07-21 02:00:00'), ('2009-07-21', '2009-07-21 00:00:00'), - ('2009-07', '2009-07-01 00:00:00'), - ('2009', '2009-01-01 00:00:00'), ('2009/07/21 02:52:12.034', '2009-07-21 02:52:12.034'), ('2009/07/21 02:52:12.000', '2009-07-21 02:52:12'), ('2009/07/21 02:52:12', '2009-07-21 02:52:12'), ('2009/07/21 02:52', '2009-07-21 02:52:00'), - ('2009/07/21 02', '2009-07-21 02:00:00'), ('2009/07/21', '2009-07-21 00:00:00'), - ('2009/07', '2009-07-01 00:00:00'), - ('2009', '2009-01-01 00:00:00'), ('2019-07-21 02:52:12', '2019-07-21 02:52:12')] self.invalid_timestamps = ['gielie', '03 Mar 2003'] self.overflow_timestamps = ['2049-07-21 02:52:12'] @@ -74,8 +68,7 @@ def test_numerical_timestamp(self): assert t == eval('katpoint.' + repr(t)) assert float(t) == self.valid_timestamps[0][0] t = katpoint.Timestamp(self.valid_timestamps[1][0]) - # self.assertAlmostEqual(t.to_ephem_date(), self.valid_timestamps[1][0], places=9) - assert t.to_ephem_date().value == self.valid_timestamps[1][0] + assert t.time == self.valid_timestamps[1][0] try: assert hash(t) == hash(t + 0.0), 'Timestamp hashes not equal' except TypeError: diff --git a/katpoint/timestamp.py b/katpoint/timestamp.py index 6b51ebd..ce5120f 100644 --- a/katpoint/timestamp.py +++ b/katpoint/timestamp.py @@ -18,17 +18,18 @@ import time import math -from functools import total_ordering import numpy as np +import astropy.time from astropy.time import Time +SECONDS_PER_DAY = astropy.time.core.erfa.DAYSEC + -@total_ordering class Timestamp: """Basic representation of time, in UTC seconds since Unix epoch. - This is loosely based on :class:`ephem.Date`. Its base representation + This is loosely based on PyEphem's `Date` object. Its base representation of time is UTC seconds since the Unix epoch, i.e. the standard Posix timestamp. Fractional seconds are allowed, as the basic data type is a Python (double-precision) float. @@ -41,14 +42,21 @@ class Timestamp: since the Unix epoch. Fractional seconds are allowed. - A string with format 'YYYY-MM-DD HH:MM:SS.SSS' or 'YYYY/MM/DD HH:MM:SS.SSS', - or any prefix thereof. Examples are '1999-12-31 12:34:56.789', '1999-12-31', - '1999-12-31 12:34:56' and even '1999'. The input string is always in UTC. + where the hours and minutes, seconds, and fractional seconds are optional. + The input string is always in UTC. Examples are: + + '1999-12-31 12:34:56.789' + '1999-12-31 12:34:56' + '1999-12-31 12:34' + '1999/12/31' - - A :class:`astropy.Time.time` object. + - A :class:`~astropy.time.Time` object. + + - A :class:`Timestamp` object, which will result in a shallow copy. Parameters ---------- - timestamp : float, string, :class:`astropy.Time.time` object or None + timestamp : float, string, :class:`~astropy.time.Time` or :class:`Timestamp` or None Timestamp, in various formats (if None, defaults to now) Arguments @@ -58,102 +66,126 @@ class Timestamp: """ def __init__(self, timestamp=None): - if isinstance(timestamp, str): - try: - timestamp = timestamp.strip().replace('/', '-') - timestamp = Time(decode(timestamp)) - except ValueError: - raise ValueError("Timestamp string '%s' not in correct format - " % (timestamp,) + - "should be 'YYYY-MM-DD HH:MM:SS' or 'YYYY/MM/DD HH:MM:SS' or prefix thereof " + - "(all UTC, fractional seconds allowed)") + format = None if timestamp is None: - self.secs = time.time() - elif isinstance(timestamp, Time): - iso = timestamp.iso - timestamp = [int(iso[:4]), int(iso[5:7]), int(iso[8:10]), int(iso[11:13]), int(iso[14:16]), float(iso[17:])] - timestamp = timestamp + [0, 0, 0] - int_secs = math.floor(timestamp[5]) - frac_secs = timestamp[5] - int_secs - timestamp[5] = int(int_secs) - self.secs = time.mktime(tuple(timestamp)) - time.timezone + frac_secs + self.time = Time.now() + elif isinstance(timestamp, Timestamp): + self.time = timestamp.time else: - self.secs = float(timestamp) - - # Keep object small by using __slots__ instead of __dict__ - __slots__ = 'secs' + # Use Astropy internal function to cast input to float64, + # string (unicode / bytes) or Time object array (0-dim for scalar) + val = astropy.time.core._make_array(timestamp) + format = None + if val.dtype.kind == 'U': + # Convert default PyEphem timestamp strings to ISO strings + val = np.char.replace(np.char.strip(val), '/', '-') + format = 'iso' + elif val.dtype.kind == 'S': + val = np.char.replace(np.char.strip(val), b'/', b'-') + format = 'iso' + elif val.dtype.kind == 'f': + format = 'unix' + self.time = Time(val, format=format, scale='utc', precision=3) + + @property + def secs(self): + return self.time.utc.unix def __repr__(self): """Short machine-friendly string representation of timestamp object.""" - return 'Timestamp(%s)' % repr(self.secs) + t = self.secs + if t.shape == (): + return 'Timestamp({!r})'.format(t) + elif t.shape == (1,): + return 'Timestamp([{!r}])'.format(t[0]) + elif t.shape == (2,): + return 'Timestamp([{!r}, {!r}])'.format(t[0], t[-1]) + else: + return 'Timestamp([{!r}, ...{} more..., {!r}])'.format(t[0], len(t) - 2, t[-1]) def __str__(self): """Verbose human-friendly string representation of timestamp object.""" return self.to_string() def __eq__(self, other): - """Test for equality""" - return self.secs == float(other) + """Test for equality.""" + return self.time == Timestamp(other).time + + def __ne__(self, other): + """Test for inequality.""" + return self.time != Timestamp(other).time def __lt__(self, other): - """Test for less than""" - return self.secs < float(other) + """Test for less than.""" + return self.time < Timestamp(other).time + + def __le__(self, other): + """Test for less than or equal to.""" + return self.time <= Timestamp(other).time + + def __gt__(self, other): + """Test for greater than.""" + return self.time > Timestamp(other).time + + def __ge__(self, other): + """Test for greater than or equal to.""" + return self.time >= Timestamp(other).time def __add__(self, other): """Add seconds (as floating-point number) to timestamp and return result.""" - return Timestamp(self.secs + other) + return Timestamp(self.time + other / SECONDS_PER_DAY) def __sub__(self, other): - """ - Subtract seconds (floating-point number is treated as a time interval) from timestamp - and return result. If used for the difference between two (absolute time) Timestamps - then the result is an interval in seconds (a floating-point number). + """Subtract seconds (floating-point time interval) from timestamp. + + If used for the difference between two (absolute time) Timestamps + then the result is an interval in seconds (a floating-point number). """ if isinstance(other, Timestamp): - return self.secs - other.secs + return (self.time - other.time).sec + elif isinstance(other, Time): + return (self.time - other).sec else: - return Timestamp(self.secs - other) + return Timestamp(self.time - other / SECONDS_PER_DAY) def __mul__(self, other): """Multiply timestamp by numerical factor (useful for processing timestamps).""" return Timestamp(self.secs * other) - def __div__(self, other): - """Divide timestamp by numerical factor (useful for processing timestamps).""" - return Timestamp(self.secs / other) - def __truediv__(self, other): """Divide timestamp by numerical factor (useful for processing timestamps).""" return Timestamp(self.secs / other) def __radd__(self, other): """Add timestamp to seconds (as floating-point number) and return result.""" - return Timestamp(other + self.secs) + return Timestamp(self.time + other / SECONDS_PER_DAY) def __iadd__(self, other): """Add seconds (as floating-point number) to timestamp in-place.""" - self.secs += other + self.time += other / SECONDS_PER_DAY return self def __rsub__(self, other): + """Subtract timestamp from seconds (as floating-point number). + + Return resulting seconds (floating-point number). This is typically + used when calculating the interval between two absolute instants + of time. """ - Subtract timestamp from seconds (as floating-point number) and return - resulting seconds (floating-point number). This is typically used when - calculating the interval between two absolute instants of time. - """ - return other - self.secs + return (Timestamp(other).time - self.time).sec def __isub__(self, other): """Subtract seconds (as floating-point number) from timestamp in-place.""" - self.secs -= other + self.time -= other / SECONDS_PER_DAY return self def __float__(self): """Convert to floating-point UTC seconds.""" - return self.secs + return float(self.secs) def __hash__(self): """Base hash on internal timestamp, just like equality operator.""" - return hash(self.secs) + return hash(self.time) def local(self): """Convert timestamp to local time string representation (for display only).""" @@ -171,67 +203,11 @@ def local(self): def to_string(self): """Convert timestamp to UTC string representation.""" - int_secs = math.floor(self.secs) - frac_secs = np.round(1000.0 * (self.secs - int_secs)) / 1000.0 - if frac_secs >= 1.0: - int_secs += 1.0 - frac_secs -= 1.0 - datetime = time.strftime('%Y-%m-%d %H:%M:%S', time.gmtime(int_secs)) - if frac_secs == 0.0: - return datetime - else: - return '%s%5.3f' % (datetime[:-1], float(datetime[-1]) + frac_secs) - - def to_ephem_date(self): - """Convert timestamp to :class:`astropy.time.Time` object.""" - int_secs = math.floor(self.secs) - timetuple = list(time.gmtime(int_secs)[:6]) - timetuple[5] += self.secs - int_secs - return Time('{0}-{1:02}-{2:02} {3:02}:{4:02}:{5:02}'.format(*timetuple)) + s = self.time.strftime('%Y-%m-%d %H:%M:%S.%f') + if isinstance(s, str) and s.endswith('.000'): + s = s[:-4] + return s def to_mjd(self): """Convert timestamp to Modified Julian Day (MJD).""" - djd = self.to_ephem_date() - return djd.mjd - - -def decode(s): - """Decode a date string like PyEphem does.""" - # Look for a dot - dot = s.find('.') - if dot > 0: - # fractional part of the seconds - f = s[dot:] - # date/time without fractional seconds - s = s[:dot] - else: - f = '.0' - - # time without fractional seconds - try: - d = time.strptime(s, '%Y-%m-%d %H:%M:%S') - except ValueError: - try: - d = time.strptime(s, '%Y-%m-%d %H:%M') - except ValueError: - try: - d = time.strptime(s, '%Y-%m-%d %H') - except ValueError: - try: - d = time.strptime(s, '%Y-%m-%d') - except ValueError: - try: - d = time.strptime(s, '%Y-%m') - except ValueError: - try: - d = time.strptime(s, '%Y') - except ValueError: - raise ValueError('unable to decode date string') - - # Convert to a unix time and add the fractional seconds - u = time.mktime(d) - - # Back to a tuple - d = time.localtime(u) - - return time.strftime('%Y-%m-%d %H:%M:%S', d) + f + return self.time.mjd From 2f3e8e53a70d643384010fdf3dc472647890ebae Mon Sep 17 00:00:00 2001 From: Ludwig Schwardt Date: Tue, 14 Jul 2020 01:23:11 +0200 Subject: [PATCH 032/122] Use pytest parametrizations Break the test class apart into individual functions. Fix string formatting. Remove the disabled timestamp overflow tests that highlighted an issue on macOS back in 2009 (see bef49f4). --- katpoint/test/test_timestamp.py | 120 ++++++++++++++++---------------- 1 file changed, 60 insertions(+), 60 deletions(-) diff --git a/katpoint/test/test_timestamp.py b/katpoint/test/test_timestamp.py index 09c51ff..4b5d60c 100644 --- a/katpoint/test/test_timestamp.py +++ b/katpoint/test/test_timestamp.py @@ -22,67 +22,67 @@ import katpoint -class TestTimestamp: - """Test timestamp creation and conversion.""" +@pytest.mark.parametrize( + 'init_value, string', + [ + (1248186982.3980861, '2009-07-21 14:36:22.398'), + (Time('2009-07-21 02:52:12.34'), '2009-07-21 02:52:12.340'), + (0, '1970-01-01 00:00:00'), + (-10, '1969-12-31 23:59:50'), + ('2009-07-21 02:52:12.034', '2009-07-21 02:52:12.034'), + ('2009-07-21 02:52:12.000', '2009-07-21 02:52:12'), + ('2009-07-21 02:52:12', '2009-07-21 02:52:12'), + ('2009-07-21 02:52', '2009-07-21 02:52:00'), + ('2009-07-21', '2009-07-21 00:00:00'), + ('2009/07/21 02:52:12.034', '2009-07-21 02:52:12.034'), + ('2009/07/21 02:52:12.000', '2009-07-21 02:52:12'), + ('2009/07/21 02:52:12', '2009-07-21 02:52:12'), + ('2009/07/21 02:52', '2009-07-21 02:52:00'), + ('2009/07/21', '2009-07-21 00:00:00'), + ('2019-07-21 02:52:12', '2019-07-21 02:52:12') + ] +) +def test_construct_valid_timestamp(init_value, string): + t = katpoint.Timestamp(init_value) + assert str(t) == string, ( + "Timestamp string ('{}') differs from expected one ('{}')".format(str(t), string)) - def setup(self): - self.valid_timestamps = [(1248186982.3980861, '2009-07-21 14:36:22.398'), - (Time('2009-07-21 02:52:12.34'), '2009-07-21 02:52:12.340'), - (0, '1970-01-01 00:00:00'), - (-10, '1969-12-31 23:59:50'), - ('2009-07-21 02:52:12.034', '2009-07-21 02:52:12.034'), - ('2009-07-21 02:52:12.000', '2009-07-21 02:52:12'), - ('2009-07-21 02:52:12', '2009-07-21 02:52:12'), - ('2009-07-21 02:52', '2009-07-21 02:52:00'), - ('2009-07-21', '2009-07-21 00:00:00'), - ('2009/07/21 02:52:12.034', '2009-07-21 02:52:12.034'), - ('2009/07/21 02:52:12.000', '2009-07-21 02:52:12'), - ('2009/07/21 02:52:12', '2009-07-21 02:52:12'), - ('2009/07/21 02:52', '2009-07-21 02:52:00'), - ('2009/07/21', '2009-07-21 00:00:00'), - ('2019-07-21 02:52:12', '2019-07-21 02:52:12')] - self.invalid_timestamps = ['gielie', '03 Mar 2003'] - self.overflow_timestamps = ['2049-07-21 02:52:12'] - def test_construct_timestamp(self): - """Test construction of timestamps.""" - for v, s in self.valid_timestamps: - t = katpoint.Timestamp(v) - assert str(t) == s, ( - "Timestamp string ('%s') differs from expected one ('%s')" - % (str(t), s)) - for v in self.invalid_timestamps: - with pytest.raises(ValueError): - katpoint.Timestamp(v) -# for v in self.overflow_timestamps: -# with pytest.raises(OverflowError): -# katpoint.Timestamp(v) +@pytest.mark.parametrize('init_value', ['gielie', '03 Mar 2003']) +def test_construct_invalid_timestamp(init_value): + with pytest.raises(ValueError): + katpoint.Timestamp(init_value) - def test_numerical_timestamp(self): - """Test numerical properties of timestamps.""" - t = katpoint.Timestamp(self.valid_timestamps[0][0]) - assert t == t + 0.0 - assert t != t + 1.0 - assert t > t - 1.0 - assert t < t + 1.0 - assert t == eval('katpoint.' + repr(t)) - assert float(t) == self.valid_timestamps[0][0] - t = katpoint.Timestamp(self.valid_timestamps[1][0]) - assert t.time == self.valid_timestamps[1][0] - try: - assert hash(t) == hash(t + 0.0), 'Timestamp hashes not equal' - except TypeError: - pytest.fail('Timestamp object not hashable') - def test_operators(self): - """Test operators defined for timestamps.""" - T = katpoint.Timestamp(self.valid_timestamps[0][0]) - S = T.secs - # Logical operators, float treated as absolute time - assert T == S - assert T < S + 1 - assert T > S - 1 - # Arithmetic operators, float treated as interval - assert isinstance(T - S, katpoint.Timestamp) - assert isinstance(S - T, float) - assert isinstance(T - T, float) +def test_numerical_timestamp(): + """Test numerical properties of timestamps.""" + t0 = 1248186982.3980861 + t = katpoint.Timestamp(t0) + assert t == t + 0.0 + assert t != t + 1.0 + assert t > t - 1.0 + assert t < t + 1.0 + assert t == eval('katpoint.' + repr(t)) + assert float(t) == t0 + t1 = Time('2009-07-21 02:52:12.34') + t = katpoint.Timestamp(t1) + assert t.time == t1 + try: + assert hash(t) == hash(t + 0.0), 'Timestamp hashes not equal' + except TypeError: + pytest.fail('Timestamp object not hashable') + + +def test_operators(): + """Test operators defined for timestamps.""" + t0 = 1248186982.3980861 + t = katpoint.Timestamp(t0) + s = t.secs + # Logical operators, float treated as absolute time + assert t == s + assert t < s + 1 + assert t > s - 1 + # Arithmetic operators, float treated as interval + assert isinstance(t - s, katpoint.Timestamp) + assert isinstance(s - t, float) + assert isinstance(t - t, float) From 539463917b89a3b1be6f0907626dcf81ac983137 Mon Sep 17 00:00:00 2001 From: Ludwig Schwardt Date: Fri, 17 Jul 2020 16:03:30 +0200 Subject: [PATCH 033/122] Limit some operations to scalars and add tests Some operations like __str__, __float__ and local() only make sense on scalar timestamps. These are legacy operations and old-style Timestamps are scalar anyway, so it should not matter. Also clean up the repr representation a bit and update docstring to mention bytes. Add tests to get to near-100% coverage on the timestamp module. Test array operations (I particularly like `Timestamp + np.ndarray` :-)). --- katpoint/test/test_timestamp.py | 49 +++++++++++++++++++++++++++++++-- katpoint/timestamp.py | 35 +++++++++++++---------- 2 files changed, 68 insertions(+), 16 deletions(-) diff --git a/katpoint/test/test_timestamp.py b/katpoint/test/test_timestamp.py index 4b5d60c..a0a470f 100644 --- a/katpoint/test/test_timestamp.py +++ b/katpoint/test/test_timestamp.py @@ -17,6 +17,7 @@ """Tests for the timestamp module.""" import pytest +import numpy as np from astropy.time import Time import katpoint @@ -38,14 +39,16 @@ ('2009/07/21 02:52:12.000', '2009-07-21 02:52:12'), ('2009/07/21 02:52:12', '2009-07-21 02:52:12'), ('2009/07/21 02:52', '2009-07-21 02:52:00'), - ('2009/07/21', '2009-07-21 00:00:00'), - ('2019-07-21 02:52:12', '2019-07-21 02:52:12') + (b'2009/07/21', '2009-07-21 00:00:00'), + (b'2020-07-17 12:40:12', '2020-07-17 12:40:12') ] ) def test_construct_valid_timestamp(init_value, string): t = katpoint.Timestamp(init_value) assert str(t) == string, ( "Timestamp string ('{}') differs from expected one ('{}')".format(str(t), string)) + # Exercise local() code path too + print(t.local()) @pytest.mark.parametrize('init_value', ['gielie', '03 Mar 2003']) @@ -54,6 +57,16 @@ def test_construct_invalid_timestamp(init_value): katpoint.Timestamp(init_value) +def test_now_and_ordering_timestamps(): + t1 = Time.now() + # We won't run this test during a leap second, promise... + t2 = katpoint.Timestamp() + assert t2 >= t1, "The second now() is not after the first now()" + assert t2 - t1 >= 0.0 + t3 = katpoint.Timestamp() + assert t2 <= t3, "The second now() is not before the third now()" + + def test_numerical_timestamp(): """Test numerical properties of timestamps.""" t0 = 1248186982.3980861 @@ -62,11 +75,17 @@ def test_numerical_timestamp(): assert t != t + 1.0 assert t > t - 1.0 assert t < t + 1.0 + # This only works for scalars... assert t == eval('katpoint.' + repr(t)) assert float(t) == t0 + t1 = Time('2009-07-21 02:52:12.34') t = katpoint.Timestamp(t1) + t += 2.0 + t -= 2.0 assert t.time == t1 + assert t / 2.0 == t * 0.5 + assert 1.0 + t == t + 1.0 try: assert hash(t) == hash(t + 0.0), 'Timestamp hashes not equal' except TypeError: @@ -86,3 +105,29 @@ def test_operators(): assert isinstance(t - s, katpoint.Timestamp) assert isinstance(s - t, float) assert isinstance(t - t, float) + + +def test_array_timestamps(): + t = katpoint.Timestamp([1234567890.0, 1234567891.0]) + with pytest.raises(TypeError): + str(t) + with pytest.raises(TypeError): + float(t) + with pytest.raises(TypeError): + t.local() + np.testing.assert_array_equal(t == 1234567890.0, [True, False]) + np.testing.assert_array_equal(t != 1234567890.0, [False, True]) + t2 = katpoint.Timestamp(1234567890.0) + # Exercise various repr code paths + print(repr(t2)) + print(repr(t2 + np.arange(0))) + print(repr(t2 + np.arange(1))) + print(repr(t2 + np.arange(2))) + print(repr(t2 + np.arange(3))) + + +@pytest.mark.parametrize('mjd', [59000.0, (59000.0, 59001.0)]) +def test_mjd_timestamp(mjd): + t = Time(mjd, format='mjd') + t2 = katpoint.Timestamp(t) + assert np.all(t2.to_mjd() == mjd) diff --git a/katpoint/timestamp.py b/katpoint/timestamp.py index ce5120f..3b3aac0 100644 --- a/katpoint/timestamp.py +++ b/katpoint/timestamp.py @@ -41,14 +41,14 @@ class Timestamp: - A floating-point number, directly representing the number of UTC seconds since the Unix epoch. Fractional seconds are allowed. - - A string with format 'YYYY-MM-DD HH:MM:SS.SSS' or 'YYYY/MM/DD HH:MM:SS.SSS', - where the hours and minutes, seconds, and fractional seconds are optional. - The input string is always in UTC. Examples are: + - A string or bytes with format 'YYYY-MM-DD HH:MM:SS.SSS' or + 'YYYY/MM/DD HH:MM:SS.SSS', where the hours and minutes, seconds, and + fractional seconds are optional. It is always in UTC. Examples are: '1999-12-31 12:34:56.789' - '1999-12-31 12:34:56' + '1999/12/31 12:34:56' '1999-12-31 12:34' - '1999/12/31' + b'1999-12-31' - A :class:`~astropy.time.Time` object. @@ -56,7 +56,7 @@ class Timestamp: Parameters ---------- - timestamp : float, string, :class:`~astropy.time.Time` or :class:`Timestamp` or None + timestamp : float, string, bytes, :class:`~astropy.time.Time`, :class:`Timestamp` or None Timestamp, in various formats (if None, defaults to now) Arguments @@ -94,17 +94,17 @@ def secs(self): def __repr__(self): """Short machine-friendly string representation of timestamp object.""" t = self.secs - if t.shape == (): - return 'Timestamp({!r})'.format(t) + if t.shape in {(), (0,)}: + return 'Timestamp({})'.format(t) elif t.shape == (1,): return 'Timestamp([{!r}])'.format(t[0]) elif t.shape == (2,): return 'Timestamp([{!r}, {!r}])'.format(t[0], t[-1]) else: - return 'Timestamp([{!r}, ...{} more..., {!r}])'.format(t[0], len(t) - 2, t[-1]) + return 'Timestamp([{!r}, ..., {!r}])'.format(t[0], t[-1]) def __str__(self): - """Verbose human-friendly string representation of timestamp object.""" + """Verbose human-friendly string representation of scalar timestamp object.""" return self.to_string() def __eq__(self, other): @@ -180,15 +180,20 @@ def __isub__(self, other): return self def __float__(self): - """Convert to floating-point UTC seconds.""" - return float(self.secs) + """Convert scalar timestamp to floating-point UTC seconds.""" + try: + return float(self.secs) + except TypeError as err: + raise TypeError('Float conversion only supported for scalar Timestamps') from err def __hash__(self): """Base hash on internal timestamp, just like equality operator.""" return hash(self.time) def local(self): - """Convert timestamp to local time string representation (for display only).""" + """Convert scalar timestamp to local time string representation (for display only).""" + if self.time.shape != (): + raise TypeError('String output only supported for scalar Timestamps') int_secs = math.floor(self.secs) frac_secs = np.round(1000.0 * (self.secs - int_secs)) / 1000.0 if frac_secs >= 1.0: @@ -203,8 +208,10 @@ def local(self): def to_string(self): """Convert timestamp to UTC string representation.""" + if self.time.shape != (): + raise TypeError('String output only supported for scalar Timestamps') s = self.time.strftime('%Y-%m-%d %H:%M:%S.%f') - if isinstance(s, str) and s.endswith('.000'): + if s.endswith('.000'): s = s[:-4] return s From 57b80f5b7495bf3924cec073617798fc6311d814 Mon Sep 17 00:00:00 2001 From: Ludwig Schwardt Date: Fri, 17 Jul 2020 16:21:03 +0200 Subject: [PATCH 034/122] Test construction from array of strings or Times This was advertised and now confirmed. --- katpoint/test/test_timestamp.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/katpoint/test/test_timestamp.py b/katpoint/test/test_timestamp.py index a0a470f..7a128ed 100644 --- a/katpoint/test/test_timestamp.py +++ b/katpoint/test/test_timestamp.py @@ -124,6 +124,13 @@ def test_array_timestamps(): print(repr(t2 + np.arange(1))) print(repr(t2 + np.arange(2))) print(repr(t2 + np.arange(3))) + # Construct from array of strings or `Time`s + t0 = katpoint.Timestamp(t.time[0]) + t1 = katpoint.Timestamp(t.time[1]) + t3 = katpoint.Timestamp([str(t0), str(t1)]) + assert all(t3 == t) + t4 = katpoint.Timestamp([t0.time, t1.time]) + assert all(t4 == t) @pytest.mark.parametrize('mjd', [59000.0, (59000.0, 59001.0)]) From 5607d1ccf5d1a07cfd40396c1bdacfa06853b0ff Mon Sep 17 00:00:00 2001 From: Ludwig Schwardt Date: Thu, 23 Jul 2020 14:54:41 +0200 Subject: [PATCH 035/122] Use Astropy units to coerce floats to seconds Instead of the clunky and smelly SECONDS_PER_DAY, use the Astropy machinery to coerce floats to seconds when doing arithmetic with Timestamps. This also promotes arithmetic with Astropy Quantities. --- katpoint/test/test_timestamp.py | 7 +++++++ katpoint/timestamp.py | 13 ++++++------- 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/katpoint/test/test_timestamp.py b/katpoint/test/test_timestamp.py index 7a128ed..d7ad66c 100644 --- a/katpoint/test/test_timestamp.py +++ b/katpoint/test/test_timestamp.py @@ -18,6 +18,7 @@ import pytest import numpy as np +import astropy.units as u from astropy.time import Time import katpoint @@ -84,8 +85,14 @@ def test_numerical_timestamp(): t += 2.0 t -= 2.0 assert t.time == t1 + t += 2.0 * u.year + t -= 2.0 * u.year + assert t.time == t1 + t2 = t + 1 * u.day + assert (t2 - t) << u.second == 1 * u.day assert t / 2.0 == t * 0.5 assert 1.0 + t == t + 1.0 + assert t - 1.0 * u.day == t1 - 1 try: assert hash(t) == hash(t + 0.0), 'Timestamp hashes not equal' except TypeError: diff --git a/katpoint/timestamp.py b/katpoint/timestamp.py index 3b3aac0..c87f0a9 100644 --- a/katpoint/timestamp.py +++ b/katpoint/timestamp.py @@ -21,10 +21,9 @@ import numpy as np import astropy.time +import astropy.units as u from astropy.time import Time -SECONDS_PER_DAY = astropy.time.core.erfa.DAYSEC - class Timestamp: """Basic representation of time, in UTC seconds since Unix epoch. @@ -133,7 +132,7 @@ def __ge__(self, other): def __add__(self, other): """Add seconds (as floating-point number) to timestamp and return result.""" - return Timestamp(self.time + other / SECONDS_PER_DAY) + return Timestamp(self.time + (other << u.second)) def __sub__(self, other): """Subtract seconds (floating-point time interval) from timestamp. @@ -146,7 +145,7 @@ def __sub__(self, other): elif isinstance(other, Time): return (self.time - other).sec else: - return Timestamp(self.time - other / SECONDS_PER_DAY) + return Timestamp(self.time - (other << u.second)) def __mul__(self, other): """Multiply timestamp by numerical factor (useful for processing timestamps).""" @@ -158,11 +157,11 @@ def __truediv__(self, other): def __radd__(self, other): """Add timestamp to seconds (as floating-point number) and return result.""" - return Timestamp(self.time + other / SECONDS_PER_DAY) + return Timestamp(self.time + (other << u.second)) def __iadd__(self, other): """Add seconds (as floating-point number) to timestamp in-place.""" - self.time += other / SECONDS_PER_DAY + self.time += (other << u.second) return self def __rsub__(self, other): @@ -176,7 +175,7 @@ def __rsub__(self, other): def __isub__(self, other): """Subtract seconds (as floating-point number) from timestamp in-place.""" - self.time -= other / SECONDS_PER_DAY + self.time -= (other << u.second) return self def __float__(self): From 8f0cef3bf44111d578f51dca2cb2ff73b3e6478a Mon Sep 17 00:00:00 2001 From: Ludwig Schwardt Date: Thu, 23 Jul 2020 15:16:45 +0200 Subject: [PATCH 036/122] Don't strip 0 fractional seconds or crash __str__ When the fractional seconds of a Timestamp is zero, the resulting '.000' suffix of its string representation was initially stripped to match the existing katpoint behaviour. With array values this becomes problematic, since some strings could be stripped while others aren't, leading to ragged string arrays. The initial solution was to raise an exception a la __float__, but this is too onerous. For starters, don't strip the suffix. It's really only the katpoint tests that complain, and those can be fixed. Anyone else is free to strip this themselves. This removes the need to test the return type of `strftime` or loop over it to convert strings. Rather just turn the array into a string too. While we are at it, use `iso` instead of `strftime` since this already matches the existing katpoint format (except for the suffix stripping). --- katpoint/test/test_timestamp.py | 24 +++++++++++------------- katpoint/timestamp.py | 9 ++------- 2 files changed, 13 insertions(+), 20 deletions(-) diff --git a/katpoint/test/test_timestamp.py b/katpoint/test/test_timestamp.py index d7ad66c..3826740 100644 --- a/katpoint/test/test_timestamp.py +++ b/katpoint/test/test_timestamp.py @@ -29,19 +29,19 @@ [ (1248186982.3980861, '2009-07-21 14:36:22.398'), (Time('2009-07-21 02:52:12.34'), '2009-07-21 02:52:12.340'), - (0, '1970-01-01 00:00:00'), - (-10, '1969-12-31 23:59:50'), + (0, '1970-01-01 00:00:00.000'), + (-10, '1969-12-31 23:59:50.000'), ('2009-07-21 02:52:12.034', '2009-07-21 02:52:12.034'), - ('2009-07-21 02:52:12.000', '2009-07-21 02:52:12'), - ('2009-07-21 02:52:12', '2009-07-21 02:52:12'), - ('2009-07-21 02:52', '2009-07-21 02:52:00'), - ('2009-07-21', '2009-07-21 00:00:00'), + ('2009-07-21 02:52:12.000', '2009-07-21 02:52:12.000'), + ('2009-07-21 02:52:12', '2009-07-21 02:52:12.000'), + ('2009-07-21 02:52', '2009-07-21 02:52:00.000'), + ('2009-07-21', '2009-07-21 00:00:00.000'), ('2009/07/21 02:52:12.034', '2009-07-21 02:52:12.034'), - ('2009/07/21 02:52:12.000', '2009-07-21 02:52:12'), - ('2009/07/21 02:52:12', '2009-07-21 02:52:12'), - ('2009/07/21 02:52', '2009-07-21 02:52:00'), - (b'2009/07/21', '2009-07-21 00:00:00'), - (b'2020-07-17 12:40:12', '2020-07-17 12:40:12') + ('2009/07/21 02:52:12.000', '2009-07-21 02:52:12.000'), + ('2009/07/21 02:52:12', '2009-07-21 02:52:12.000'), + ('2009/07/21 02:52', '2009-07-21 02:52:00.000'), + (b'2009/07/21', '2009-07-21 00:00:00.000'), + (b'2020-07-17 12:40:12', '2020-07-17 12:40:12.000') ] ) def test_construct_valid_timestamp(init_value, string): @@ -116,8 +116,6 @@ def test_operators(): def test_array_timestamps(): t = katpoint.Timestamp([1234567890.0, 1234567891.0]) - with pytest.raises(TypeError): - str(t) with pytest.raises(TypeError): float(t) with pytest.raises(TypeError): diff --git a/katpoint/timestamp.py b/katpoint/timestamp.py index c87f0a9..6c4765a 100644 --- a/katpoint/timestamp.py +++ b/katpoint/timestamp.py @@ -103,7 +103,7 @@ def __repr__(self): return 'Timestamp([{!r}, ..., {!r}])'.format(t[0], t[-1]) def __str__(self): - """Verbose human-friendly string representation of scalar timestamp object.""" + """Verbose human-friendly string representation of timestamp object.""" return self.to_string() def __eq__(self, other): @@ -207,12 +207,7 @@ def local(self): def to_string(self): """Convert timestamp to UTC string representation.""" - if self.time.shape != (): - raise TypeError('String output only supported for scalar Timestamps') - s = self.time.strftime('%Y-%m-%d %H:%M:%S.%f') - if s.endswith('.000'): - s = s[:-4] - return s + return str(self.time.iso) def to_mjd(self): """Convert timestamp to Modified Julian Day (MJD).""" From 334f200c8f7a748931e070bc3303727b9092c441 Mon Sep 17 00:00:00 2001 From: Ludwig Schwardt Date: Fri, 24 Jul 2020 12:46:24 +0200 Subject: [PATCH 037/122] Avoid internal Astropy function The `astropy.time.core._make_array` function is `np.array` in essence, while also coercing any numerical input to float64. We don't need the last bit, so just do the array part. Interpret any ints or floats as Unix timestamps, but leave out bools, unlike _make_array (who uses bools for timestamps??). --- katpoint/timestamp.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/katpoint/timestamp.py b/katpoint/timestamp.py index 6c4765a..48e7a40 100644 --- a/katpoint/timestamp.py +++ b/katpoint/timestamp.py @@ -20,7 +20,6 @@ import math import numpy as np -import astropy.time import astropy.units as u from astropy.time import Time @@ -71,9 +70,8 @@ def __init__(self, timestamp=None): elif isinstance(timestamp, Timestamp): self.time = timestamp.time else: - # Use Astropy internal function to cast input to float64, - # string (unicode / bytes) or Time object array (0-dim for scalar) - val = astropy.time.core._make_array(timestamp) + # Convert to array to simplify both array/scalar and string/bytes handling + val = np.asarray(timestamp) format = None if val.dtype.kind == 'U': # Convert default PyEphem timestamp strings to ISO strings @@ -82,7 +80,8 @@ def __init__(self, timestamp=None): elif val.dtype.kind == 'S': val = np.char.replace(np.char.strip(val), b'/', b'-') format = 'iso' - elif val.dtype.kind == 'f': + elif val.dtype.kind in 'iuf': + # Consider any number to be a Unix timestamp format = 'unix' self.time = Time(val, format=format, scale='utc', precision=3) From f83fb0d71d7fff7335ce387eef6c8e2d0e7a7586 Mon Sep 17 00:00:00 2001 From: Ludwig Schwardt Date: Fri, 24 Jul 2020 13:00:31 +0200 Subject: [PATCH 038/122] Use NumPy print options to render __repr__ Instead of inventing our own format, use NumPy's pretty printing. We need a custom formatter because the suppress=True print option only works on values < 1e8 and the Unix timestamps we care about are bigger than that. --- katpoint/timestamp.py | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/katpoint/timestamp.py b/katpoint/timestamp.py index 48e7a40..ab1081e 100644 --- a/katpoint/timestamp.py +++ b/katpoint/timestamp.py @@ -91,15 +91,11 @@ def secs(self): def __repr__(self): """Short machine-friendly string representation of timestamp object.""" - t = self.secs - if t.shape in {(), (0,)}: - return 'Timestamp({})'.format(t) - elif t.shape == (1,): - return 'Timestamp([{!r}])'.format(t[0]) - elif t.shape == (2,): - return 'Timestamp([{!r}, {!r}])'.format(t[0], t[-1]) - else: - return 'Timestamp([{!r}, ..., {!r}])'.format(t[0], t[-1]) + # We need a custom formatter because suppress=True only works on values < 1e8 + # and today's Unix timestamps are bigger than that + formatter = '{{:.{:d}f}}'.format(self.time.precision).format + with np.printoptions(threshold=2, edgeitems=1, formatter={'float': formatter}): + return 'Timestamp({})'.format(self.secs) def __str__(self): """Verbose human-friendly string representation of timestamp object.""" From 95897ce0a67539423a7018127d98327ae1809ee6 Mon Sep 17 00:00:00 2001 From: Ludwig Schwardt Date: Fri, 24 Jul 2020 14:18:19 +0200 Subject: [PATCH 039/122] Replicate internals in copy constructor When initialising a `Timestamp` from another `Timestamp`, instead of referencing the underlying `Time` object with dubious consequences, make a replica instead. This shares the immutable internal representation of `Time` (i.e. the `jd1` and `jd2` members, which make up the bulk of the object if it's a large array), but copies the ancillary attributes related to formatting. --- katpoint/timestamp.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/katpoint/timestamp.py b/katpoint/timestamp.py index ab1081e..da92894 100644 --- a/katpoint/timestamp.py +++ b/katpoint/timestamp.py @@ -68,7 +68,7 @@ def __init__(self, timestamp=None): if timestamp is None: self.time = Time.now() elif isinstance(timestamp, Timestamp): - self.time = timestamp.time + self.time = timestamp.time.replicate() else: # Convert to array to simplify both array/scalar and string/bytes handling val = np.asarray(timestamp) From 67a9f3faefe751c979aa37c4460cd332b46b8292 Mon Sep 17 00:00:00 2001 From: Ludwig Schwardt Date: Fri, 24 Jul 2020 14:23:03 +0200 Subject: [PATCH 040/122] Update Timestamp docstring Point out that this is now based on `astropy.time.Time` and highlight the differences. Specify that it supports an array of timestamps. --- katpoint/timestamp.py | 41 ++++++++++++++++++++++++++++++----------- 1 file changed, 30 insertions(+), 11 deletions(-) diff --git a/katpoint/timestamp.py b/katpoint/timestamp.py index da92894..3cf8ec6 100644 --- a/katpoint/timestamp.py +++ b/katpoint/timestamp.py @@ -25,21 +25,18 @@ class Timestamp: - """Basic representation of time, in UTC seconds since Unix epoch. + """Basic representation of time(s), in UTC seconds since Unix epoch. - This is loosely based on PyEphem's `Date` object. Its base representation - of time is UTC seconds since the Unix epoch, i.e. the standard Posix - timestamp. Fractional seconds are allowed, as the basic data - type is a Python (double-precision) float. + This is loosely based on PyEphem's `Date` object, but uses an Astropy + `Time` object as internal representation. Like `Time` it can contain + a multi-dimensional array of timestamps. The following input formats are accepted for a timestamp: - - None, which uses the current time (the default). - - A floating-point number, directly representing the number of UTC seconds since the Unix epoch. Fractional seconds are allowed. - - A string or bytes with format 'YYYY-MM-DD HH:MM:SS.SSS' or + - A string or bytes with format 'YYYY-MM-DD HH:MM:SS.SSS' (RFC 3339) or 'YYYY/MM/DD HH:MM:SS.SSS', where the hours and minutes, seconds, and fractional seconds are optional. It is always in UTC. Examples are: @@ -50,17 +47,39 @@ class Timestamp: - A :class:`~astropy.time.Time` object. - - A :class:`Timestamp` object, which will result in a shallow copy. + - A sequence or NumPy array of one of the above types. + + - None, which uses the current time (the default). + + - Another :class:`Timestamp` object, which will result in a copy. Parameters ---------- - timestamp : float, string, bytes, :class:`~astropy.time.Time`, :class:`Timestamp` or None + timestamp : float, string, bytes, :class:`~astropy.time.Time`, sequence or + array of the former, :class:`Timestamp` or None, optional Timestamp, in various formats (if None, defaults to now) Arguments --------- - secs : float + time : :class:`~astropy.time.Time` + Underlying `Time` object + secs : float or array of float Timestamp as UTC seconds since Unix epoch + + Notes + ----- + This differs from :class:`~astropy.time.Time` in the following respects: + + - Numbers are interpreted as Unix timestamps during initialisation; + `Timestamp(1234567890)` is equivalent to `Time(1234567890, format='unix')` + (while `Time(1234567890)` is not allowed because it lacks a format). + + - Arithmetic is done in seconds instead of days (in the absence of units). + + - Date strings may contain slashes (a leftover from PyEphem / XEphem). + + - Empty initialisation results in the current time, so `Timestamp()` + is equivalent to `Time.now()` (while `Time()` is not allowed). """ def __init__(self, timestamp=None): From e6dc970d0fda3861bacab41610391c4a85e4f8e3 Mon Sep 17 00:00:00 2001 From: Ludwig Schwardt Date: Sat, 25 Jul 2020 23:35:24 +0200 Subject: [PATCH 041/122] Construct a Timestamp from an array of Timestamps Construct a multidimensional `Timestamp` from a sequence or array of individual `Timestamps`. Do this by leveraging Astropy's support for constructing multidimensional `Time`s from an array of `Time`s. In essence, turn each Timestamp into a replica of its internal `Time` and construct a new `Time` from that. This also supercedes the existing scalar copy constructor. This is useful to support e.g. geometric_delay. --- katpoint/test/test_timestamp.py | 9 +++++++-- katpoint/timestamp.py | 13 +++++++------ 2 files changed, 14 insertions(+), 8 deletions(-) diff --git a/katpoint/test/test_timestamp.py b/katpoint/test/test_timestamp.py index 3826740..98a446b 100644 --- a/katpoint/test/test_timestamp.py +++ b/katpoint/test/test_timestamp.py @@ -129,13 +129,18 @@ def test_array_timestamps(): print(repr(t2 + np.arange(1))) print(repr(t2 + np.arange(2))) print(repr(t2 + np.arange(3))) - # Construct from array of strings or `Time`s + # Construct from sequence or array of strings or `Time`s or `Timestamp`s t0 = katpoint.Timestamp(t.time[0]) t1 = katpoint.Timestamp(t.time[1]) t3 = katpoint.Timestamp([str(t0), str(t1)]) + assert t3.time.shape == (2,) assert all(t3 == t) - t4 = katpoint.Timestamp([t0.time, t1.time]) + t4 = katpoint.Timestamp((t0.time, t1.time)) + assert t4.time.shape == (2,) assert all(t4 == t) + t5 = katpoint.Timestamp(np.array((t0, t1))) + assert t5.time.shape == (2,) + assert all(t5 == t) @pytest.mark.parametrize('mjd', [59000.0, (59000.0, 59001.0)]) diff --git a/katpoint/timestamp.py b/katpoint/timestamp.py index 3cf8ec6..2f5e091 100644 --- a/katpoint/timestamp.py +++ b/katpoint/timestamp.py @@ -47,16 +47,16 @@ class Timestamp: - A :class:`~astropy.time.Time` object. + - Another :class:`Timestamp` object, which will result in a copy. + - A sequence or NumPy array of one of the above types. - None, which uses the current time (the default). - - Another :class:`Timestamp` object, which will result in a copy. - Parameters ---------- - timestamp : float, string, bytes, :class:`~astropy.time.Time`, sequence or - array of the former, :class:`Timestamp` or None, optional + timestamp : :class:`~astropy.time.Time`, :class:`Timestamp`, float, string, + bytes, sequence or array of any of the former, or None, optional Timestamp, in various formats (if None, defaults to now) Arguments @@ -86,11 +86,12 @@ def __init__(self, timestamp=None): format = None if timestamp is None: self.time = Time.now() - elif isinstance(timestamp, Timestamp): - self.time = timestamp.time.replicate() else: # Convert to array to simplify both array/scalar and string/bytes handling val = np.asarray(timestamp) + # Extract copies of Time objects from inside Timestamps + if val.size > 0 and isinstance(val.flat[0], Timestamp): + val = np.vectorize(lambda ts: ts.time.replicate())(val) format = None if val.dtype.kind == 'U': # Convert default PyEphem timestamp strings to ISO strings From 96dc528e6a4d023dad030af455f34094c353c7b3 Mon Sep 17 00:00:00 2001 From: Ludwig Schwardt Date: Wed, 29 Jul 2020 00:26:19 +0200 Subject: [PATCH 042/122] Support TimeDelta for timestamp differences Turn all addition and subtraction operands into `TimeDelta` objects, which also takes care of Quantities and other TimeDeltas. This also implements a bunch of MR suggestions. Check that timezones are not allowed in ISO strings for Timestamp construction. Check that Timestamps can't be complex (yet another dtype). Check that `katpoint.Timestamp()` is `Time.now()`. Add several tests for addition and subtraction to find corner cases. Fix docstrings. --- katpoint/test/test_timestamp.py | 97 +++++++++++++++++++++++++++------ katpoint/timestamp.py | 31 ++++++----- 2 files changed, 98 insertions(+), 30 deletions(-) diff --git a/katpoint/test/test_timestamp.py b/katpoint/test/test_timestamp.py index 98a446b..5c228c7 100644 --- a/katpoint/test/test_timestamp.py +++ b/katpoint/test/test_timestamp.py @@ -16,10 +16,13 @@ """Tests for the timestamp module.""" +import warnings +from unittest.mock import patch + import pytest import numpy as np import astropy.units as u -from astropy.time import Time +from astropy.time import Time, TimeDelta import katpoint @@ -52,20 +55,32 @@ def test_construct_valid_timestamp(init_value, string): print(t.local()) -@pytest.mark.parametrize('init_value', ['gielie', '03 Mar 2003']) +@pytest.mark.parametrize('init_value', + ['2020-07-28T18:18:18.000', # ISO 8601 with a 'T' is invalid + '2020-07-28 18:18:18.000+02:00', # Time zones are not accepted + 'gielie', '03 Mar 2003', 2j]) def test_construct_invalid_timestamp(init_value): with pytest.raises(ValueError): - katpoint.Timestamp(init_value) + with warnings.catch_warnings(): + warnings.simplefilter('ignore', np.ComplexWarning) + katpoint.Timestamp(init_value) + + +def test_current_timestamp(): + t0 = Time.now() + with patch.object(Time, 'now', side_effect=lambda: t0): + assert katpoint.Timestamp() == t0 -def test_now_and_ordering_timestamps(): - t1 = Time.now() - # We won't run this test during a leap second, promise... - t2 = katpoint.Timestamp() - assert t2 >= t1, "The second now() is not after the first now()" - assert t2 - t1 >= 0.0 - t3 = katpoint.Timestamp() - assert t2 <= t3, "The second now() is not before the third now()" +def test_timestamp_ordering(): + t1 = katpoint.Timestamp(1234567890) + t2 = katpoint.Timestamp(1234567891) + assert t2 >= t1 + assert t2 >= t1.time + assert t2 >= t1.secs + assert t2 > t1 + assert t1 <= t2 + assert t1 < t2 def test_numerical_timestamp(): @@ -113,6 +128,59 @@ def test_operators(): assert isinstance(s - t, float) assert isinstance(t - t, float) + # Check various additions and subtractions + def approx_equal(x, y, **kwargs): + return x.secs == pytest.approx(y, **kwargs) + # Timestamp + interval + assert approx_equal(t + 1, t0 + 1) + assert approx_equal(t + 1 * u.second, t0 + 1) + assert approx_equal(t + TimeDelta(1.0, format='sec', scale='tai'), t0 + 1) + # interval + Timestamp + assert approx_equal(1 + t, t0 + 1) + with pytest.raises(TypeError): # why does Quantity not return NotImplemented here? + assert approx_equal(1 * u.second + t, t0 + 1) + assert approx_equal(TimeDelta(1.0, format='sec', scale='tai') + t, t0 + 1) + # Timestamp + Timestamp + with pytest.raises(ValueError): + t + t + with pytest.raises(ValueError): + t + t.time + # Timestamp - interval + assert approx_equal(t - 1, t0 - 1) + assert approx_equal(t - 1 * u.second, t0 - 1) + assert approx_equal(t - TimeDelta(1.0, format='sec', scale='tai'), t0 - 1) + # This differs from PyEphem-based katpoint: leap seconds! + assert approx_equal(t - t0, 26.0, rel=1e-5) # float t0 is an interval here... + # Timestamp - Timestamp + assert t - katpoint.Timestamp(t0) == 0.0 + assert t - t.time == 0.0 + assert t0 - t == 0.0 # float t0 is a Unix timestamp here... + assert t.time - t == 0.0 + # Timestamp += interval + t += 1 + assert approx_equal(t, t0 + 1) + t += 1 * u.second + assert approx_equal(t, t0 + 2) + t += TimeDelta(1.0, format='sec', scale='tai') + assert approx_equal(t, t0 + 3) + # Timestamp += Timestamp + with pytest.raises(ValueError): + t += t + with pytest.raises(ValueError): + t += t.time + # Timestamp -= interval + t -= 1 + assert approx_equal(t, t0 + 2) + t -= 1 * u.second + assert approx_equal(t, t0 + 1) + t -= TimeDelta(1.0, format='sec', scale='tai') + assert approx_equal(t, t0) + # Timestamp -= Timestamp + with pytest.raises(ValueError): + t -= t + with pytest.raises(ValueError): + t -= t.time + def test_array_timestamps(): t = katpoint.Timestamp([1234567890.0, 1234567891.0]) @@ -123,12 +191,7 @@ def test_array_timestamps(): np.testing.assert_array_equal(t == 1234567890.0, [True, False]) np.testing.assert_array_equal(t != 1234567890.0, [False, True]) t2 = katpoint.Timestamp(1234567890.0) - # Exercise various repr code paths - print(repr(t2)) - print(repr(t2 + np.arange(0))) - print(repr(t2 + np.arange(1))) - print(repr(t2 + np.arange(2))) - print(repr(t2 + np.arange(3))) + assert repr(t2 + np.arange(3)) == 'Timestamp([1234567890.000 ... 1234567892.000])' # Construct from sequence or array of strings or `Time`s or `Timestamp`s t0 = katpoint.Timestamp(t.time[0]) t1 = katpoint.Timestamp(t.time[1]) diff --git a/katpoint/timestamp.py b/katpoint/timestamp.py index 2f5e091..17cef38 100644 --- a/katpoint/timestamp.py +++ b/katpoint/timestamp.py @@ -20,8 +20,12 @@ import math import numpy as np -import astropy.units as u -from astropy.time import Time +from astropy.time import Time, TimeDelta + + +def delta_seconds(x): + """Construct a `TimeDelta` in TAI seconds.""" + return TimeDelta(x, format='sec', scale='tai') class Timestamp: @@ -36,9 +40,10 @@ class Timestamp: - A floating-point number, directly representing the number of UTC seconds since the Unix epoch. Fractional seconds are allowed. - - A string or bytes with format 'YYYY-MM-DD HH:MM:SS.SSS' (RFC 3339) or - 'YYYY/MM/DD HH:MM:SS.SSS', where the hours and minutes, seconds, and - fractional seconds are optional. It is always in UTC. Examples are: + - A string or bytes with format 'YYYY-MM-DD HH:MM:SS.SSS' (ISO 8601 with + a space separator) or 'YYYY/MM/DD HH:MM:SS.SSS' (XEphem), where the hours + and minutes, seconds, and fractional seconds are optional. It is always + in UTC. Examples are: '1999-12-31 12:34:56.789' '1999/12/31 12:34:56' @@ -59,8 +64,8 @@ class Timestamp: bytes, sequence or array of any of the former, or None, optional Timestamp, in various formats (if None, defaults to now) - Arguments - --------- + Attributes + ---------- time : :class:`~astropy.time.Time` Underlying `Time` object secs : float or array of float @@ -147,7 +152,7 @@ def __ge__(self, other): def __add__(self, other): """Add seconds (as floating-point number) to timestamp and return result.""" - return Timestamp(self.time + (other << u.second)) + return Timestamp(self.time + delta_seconds(other)) def __sub__(self, other): """Subtract seconds (floating-point time interval) from timestamp. @@ -157,10 +162,10 @@ def __sub__(self, other): """ if isinstance(other, Timestamp): return (self.time - other.time).sec - elif isinstance(other, Time): + elif isinstance(other, Time) and not isinstance(other, TimeDelta): return (self.time - other).sec else: - return Timestamp(self.time - (other << u.second)) + return Timestamp(self.time - delta_seconds(other)) def __mul__(self, other): """Multiply timestamp by numerical factor (useful for processing timestamps).""" @@ -172,11 +177,11 @@ def __truediv__(self, other): def __radd__(self, other): """Add timestamp to seconds (as floating-point number) and return result.""" - return Timestamp(self.time + (other << u.second)) + return Timestamp(self.time + delta_seconds(other)) def __iadd__(self, other): """Add seconds (as floating-point number) to timestamp in-place.""" - self.time += (other << u.second) + self.time += delta_seconds(other) return self def __rsub__(self, other): @@ -190,7 +195,7 @@ def __rsub__(self, other): def __isub__(self, other): """Subtract seconds (as floating-point number) from timestamp in-place.""" - self.time -= (other << u.second) + self.time -= delta_seconds(other) return self def __float__(self): From ae7280472b5d381cba0edef74a997f08834de240 Mon Sep 17 00:00:00 2001 From: Ludwig Schwardt Date: Wed, 29 Jul 2020 13:56:33 +0200 Subject: [PATCH 043/122] Support array-valued string representations Extend the `local()` method to return an array of strings if the underlying `Time` is array-valued. Also let `to_string()` be array-valued in this case and rather cast to string inside __str__ itself, since this is more flexible. --- katpoint/test/test_timestamp.py | 6 +++--- katpoint/timestamp.py | 29 +++++++++++------------------ 2 files changed, 14 insertions(+), 21 deletions(-) diff --git a/katpoint/test/test_timestamp.py b/katpoint/test/test_timestamp.py index 5c228c7..3d43bd4 100644 --- a/katpoint/test/test_timestamp.py +++ b/katpoint/test/test_timestamp.py @@ -186,12 +186,12 @@ def test_array_timestamps(): t = katpoint.Timestamp([1234567890.0, 1234567891.0]) with pytest.raises(TypeError): float(t) - with pytest.raises(TypeError): - t.local() np.testing.assert_array_equal(t == 1234567890.0, [True, False]) np.testing.assert_array_equal(t != 1234567890.0, [False, True]) t2 = katpoint.Timestamp(1234567890.0) - assert repr(t2 + np.arange(3)) == 'Timestamp([1234567890.000 ... 1234567892.000])' + t_array = t2 + np.arange(3) + assert repr(t_array) == 'Timestamp([1234567890.000 ... 1234567892.000])' + assert t_array.local().shape == (3,) # Construct from sequence or array of strings or `Time`s or `Timestamp`s t0 = katpoint.Timestamp(t.time[0]) t1 = katpoint.Timestamp(t.time[1]) diff --git a/katpoint/timestamp.py b/katpoint/timestamp.py index 17cef38..03da5fd 100644 --- a/katpoint/timestamp.py +++ b/katpoint/timestamp.py @@ -17,7 +17,6 @@ """A Timestamp object.""" import time -import math import numpy as np from astropy.time import Time, TimeDelta @@ -124,7 +123,7 @@ def __repr__(self): def __str__(self): """Verbose human-friendly string representation of timestamp object.""" - return self.to_string() + return str(self.to_string()) def __eq__(self, other): """Test for equality.""" @@ -210,24 +209,18 @@ def __hash__(self): return hash(self.time) def local(self): - """Convert scalar timestamp to local time string representation (for display only).""" - if self.time.shape != (): - raise TypeError('String output only supported for scalar Timestamps') - int_secs = math.floor(self.secs) - frac_secs = np.round(1000.0 * (self.secs - int_secs)) / 1000.0 - if frac_secs >= 1.0: - int_secs += 1.0 - frac_secs -= 1.0 - datetime = time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(int_secs)) - timezone = time.strftime('%Z', time.localtime(int_secs)) - if frac_secs == 0.0: - return '%s %s' % (datetime, timezone) - else: - return '%s%5.3f %s' % (datetime[:-1], float(datetime[-1]) + frac_secs, timezone) + """Local time string representation (str or array of str).""" + frac_secs, int_secs = np.modf(np.round(self.secs, decimals=self.time.precision)) + + def local_time_string(f, i): + format_string = '%Y-%m-%d %H:%M:%S.{:.0f} %Z'.format(1000 * f) + return time.strftime(format_string, time.localtime(i)) + local_str = np.vectorize(local_time_string)(frac_secs, int_secs) + return local_str if local_str.ndim else local_str.item() def to_string(self): - """Convert timestamp to UTC string representation.""" - return str(self.time.iso) + """UTC string representation (str or array of str).""" + return self.time.iso def to_mjd(self): """Convert timestamp to Modified Julian Day (MJD).""" From 4bfa38ce9de9e051f329a0c55699baa872f01ec8 Mon Sep 17 00:00:00 2001 From: Ludwig Schwardt Date: Wed, 29 Jul 2020 14:14:21 +0200 Subject: [PATCH 044/122] Fix construction from multi-dim Time object This seems to be an Astropy limitation, so add a workaround for now. --- katpoint/test/test_timestamp.py | 11 ++++++++--- katpoint/timestamp.py | 3 ++- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/katpoint/test/test_timestamp.py b/katpoint/test/test_timestamp.py index 3d43bd4..097d874 100644 --- a/katpoint/test/test_timestamp.py +++ b/katpoint/test/test_timestamp.py @@ -189,9 +189,9 @@ def test_array_timestamps(): np.testing.assert_array_equal(t == 1234567890.0, [True, False]) np.testing.assert_array_equal(t != 1234567890.0, [False, True]) t2 = katpoint.Timestamp(1234567890.0) - t_array = t2 + np.arange(3) - assert repr(t_array) == 'Timestamp([1234567890.000 ... 1234567892.000])' - assert t_array.local().shape == (3,) + t_array_1d = t2 + np.arange(3) + assert repr(t_array_1d) == 'Timestamp([1234567890.000 ... 1234567892.000])' + assert t_array_1d.local().shape == (3,) # Construct from sequence or array of strings or `Time`s or `Timestamp`s t0 = katpoint.Timestamp(t.time[0]) t1 = katpoint.Timestamp(t.time[1]) @@ -204,6 +204,11 @@ def test_array_timestamps(): t5 = katpoint.Timestamp(np.array((t0, t1))) assert t5.time.shape == (2,) assert all(t5 == t) + # Construct from 2-dimensional array of floats or a 2-D `Time` + array_2d = [[1234567890.0, 1234567891.0], [1234567892.0, 1234567893.0]] + t_array_2d = katpoint.Timestamp(array_2d) + t_array_2d = katpoint.Timestamp(t_array_2d.time) + np.testing.assert_array_equal(t_array_2d.secs, array_2d) @pytest.mark.parametrize('mjd', [59000.0, (59000.0, 59001.0)]) diff --git a/katpoint/timestamp.py b/katpoint/timestamp.py index 03da5fd..0495d04 100644 --- a/katpoint/timestamp.py +++ b/katpoint/timestamp.py @@ -107,7 +107,8 @@ def __init__(self, timestamp=None): elif val.dtype.kind in 'iuf': # Consider any number to be a Unix timestamp format = 'unix' - self.time = Time(val, format=format, scale='utc', precision=3) + self.time = Time(val.ravel(), format=format, + scale='utc', precision=3).reshape(val.shape) @property def secs(self): From 82dcabc0d6f43c6218ec251732117d02537b3d6c Mon Sep 17 00:00:00 2001 From: Ludwig Schwardt Date: Wed, 29 Jul 2020 16:39:33 +0200 Subject: [PATCH 045/122] More MR fixes Fix local() fractional seconds. It was broken for values below 0.1, outputting e.g. 0.12 instead of 0.012. Use the Time output precision consistently in local(). Add tests for this, also changing the precision during the test. Remove workaround for multi-dimensional array of Times/Timestamps. Rather treat Timestamp and Time as special inputs to Timestamp, resulting in a straight copy of the underlying time object and avoiding normalisation via ndarray. It is still possible to crash Astropy with a multi-dimensional array of objects, but just don't do that :-) Let Astropy fix the use case instead if it's that important. Add more string representation tests. Mock better. Describe time format better in docstring. --- katpoint/test/test_timestamp.py | 19 ++++++++++++++++++- katpoint/timestamp.py | 19 ++++++++++++------- 2 files changed, 30 insertions(+), 8 deletions(-) diff --git a/katpoint/test/test_timestamp.py b/katpoint/test/test_timestamp.py index 097d874..b78955d 100644 --- a/katpoint/test/test_timestamp.py +++ b/katpoint/test/test_timestamp.py @@ -66,9 +66,23 @@ def test_construct_invalid_timestamp(init_value): katpoint.Timestamp(init_value) +def test_string_representations(): + t = katpoint.Timestamp(1234567890.01234) + assert t.to_string() == '2009-02-13 23:31:30.012' + assert str(t) == '2009-02-13 23:31:30.012' + assert repr(t) == 'Timestamp(1234567890.01234)' + # XXX We could mock time.localtime to control the output of Timestamp.local() + assert len(t.local().split()) == 3 + assert t.local().split()[1].endswith('30.012') + # Change the output precision + t.time.precision = 5 + assert str(t) == '2009-02-13 23:31:30.01234' + assert t.local().split()[1].endswith('30.01234') + + def test_current_timestamp(): t0 = Time.now() - with patch.object(Time, 'now', side_effect=lambda: t0): + with patch.object(Time, 'now', return_value=t0): assert katpoint.Timestamp() == t0 @@ -190,6 +204,9 @@ def test_array_timestamps(): np.testing.assert_array_equal(t != 1234567890.0, [False, True]) t2 = katpoint.Timestamp(1234567890.0) t_array_1d = t2 + np.arange(3) + np.testing.assert_array_equal(t_array_1d.to_string(), ['2009-02-13 23:31:30.000', + '2009-02-13 23:31:31.000', + '2009-02-13 23:31:32.000']) assert repr(t_array_1d) == 'Timestamp([1234567890.000 ... 1234567892.000])' assert t_array_1d.local().shape == (3,) # Construct from sequence or array of strings or `Time`s or `Timestamp`s diff --git a/katpoint/timestamp.py b/katpoint/timestamp.py index 0495d04..79fbcbc 100644 --- a/katpoint/timestamp.py +++ b/katpoint/timestamp.py @@ -39,8 +39,8 @@ class Timestamp: - A floating-point number, directly representing the number of UTC seconds since the Unix epoch. Fractional seconds are allowed. - - A string or bytes with format 'YYYY-MM-DD HH:MM:SS.SSS' (ISO 8601 with - a space separator) or 'YYYY/MM/DD HH:MM:SS.SSS' (XEphem), where the hours + - A string or bytes with format 'YYYY-MM-DD HH:MM:SS.SSS' (Astropy 'iso' + format) or 'YYYY/MM/DD HH:MM:SS.SSS' (XEphem format), where the hours and minutes, seconds, and fractional seconds are optional. It is always in UTC. Examples are: @@ -90,12 +90,16 @@ def __init__(self, timestamp=None): format = None if timestamp is None: self.time = Time.now() + elif isinstance(timestamp, Timestamp): + self.time = timestamp.time.replicate() + elif isinstance(timestamp, Time): + self.time = timestamp.replicate() else: # Convert to array to simplify both array/scalar and string/bytes handling val = np.asarray(timestamp) # Extract copies of Time objects from inside Timestamps if val.size > 0 and isinstance(val.flat[0], Timestamp): - val = np.vectorize(lambda ts: ts.time.replicate())(val) + val = np.vectorize(lambda ts: ts.time)(val) format = None if val.dtype.kind == 'U': # Convert default PyEphem timestamp strings to ISO strings @@ -107,8 +111,7 @@ def __init__(self, timestamp=None): elif val.dtype.kind in 'iuf': # Consider any number to be a Unix timestamp format = 'unix' - self.time = Time(val.ravel(), format=format, - scale='utc', precision=3).reshape(val.shape) + self.time = Time(val, format=format, scale='utc', precision=3) @property def secs(self): @@ -211,10 +214,12 @@ def __hash__(self): def local(self): """Local time string representation (str or array of str).""" - frac_secs, int_secs = np.modf(np.round(self.secs, decimals=self.time.precision)) + prec = self.time.precision + frac_secs, int_secs = np.modf(np.round(self.secs, decimals=prec)) def local_time_string(f, i): - format_string = '%Y-%m-%d %H:%M:%S.{:.0f} %Z'.format(1000 * f) + format_string = '%Y-%m-%d %H:%M:%S.{:0{width}.0f} %Z'.format( + f * 10 ** prec, width=prec) return time.strftime(format_string, time.localtime(i)) local_str = np.vectorize(local_time_string)(frac_secs, int_secs) return local_str if local_str.ndim else local_str.item() From d285eafb967518d254ee52b02b6dd9b7122eefcd Mon Sep 17 00:00:00 2001 From: Ludwig Schwardt Date: Wed, 29 Jul 2020 18:20:35 +0200 Subject: [PATCH 046/122] Don't construct Timestamp from TimeDelta A TimeDelta is actually derived from Time, but should not be used to construct a Timestamp since it represents an interval and not a time instant. Fail it explicitly and add tests. --- katpoint/test/test_timestamp.py | 17 ++++++++++++----- katpoint/timestamp.py | 10 ++++++++-- 2 files changed, 20 insertions(+), 7 deletions(-) diff --git a/katpoint/test/test_timestamp.py b/katpoint/test/test_timestamp.py index b78955d..8981f8c 100644 --- a/katpoint/test/test_timestamp.py +++ b/katpoint/test/test_timestamp.py @@ -44,7 +44,7 @@ ('2009/07/21 02:52:12', '2009-07-21 02:52:12.000'), ('2009/07/21 02:52', '2009-07-21 02:52:00.000'), (b'2009/07/21', '2009-07-21 00:00:00.000'), - (b'2020-07-17 12:40:12', '2020-07-17 12:40:12.000') + (b'2020-07-17 12:40:12', '2020-07-17 12:40:12.000'), ] ) def test_construct_valid_timestamp(init_value, string): @@ -55,10 +55,17 @@ def test_construct_valid_timestamp(init_value, string): print(t.local()) -@pytest.mark.parametrize('init_value', - ['2020-07-28T18:18:18.000', # ISO 8601 with a 'T' is invalid - '2020-07-28 18:18:18.000+02:00', # Time zones are not accepted - 'gielie', '03 Mar 2003', 2j]) +@pytest.mark.parametrize( + 'init_value', + [ + 'gielie', + '03 Mar 2003', + 2j, # An unsupported NumPy dtype + '2020-07-28T18:18:18.000', # ISO 8601 with a 'T' is invalid + '2020-07-28 18:18:18.000+02:00', # Time zones are not accepted + TimeDelta(1.0, format='sec', scale='tai'), # A TimeDelta is the wrong kind of Time + ] +) def test_construct_invalid_timestamp(init_value): with pytest.raises(ValueError): with warnings.catch_warnings(): diff --git a/katpoint/timestamp.py b/katpoint/timestamp.py index 79fbcbc..5c174bb 100644 --- a/katpoint/timestamp.py +++ b/katpoint/timestamp.py @@ -49,7 +49,7 @@ class Timestamp: '1999-12-31 12:34' b'1999-12-31' - - A :class:`~astropy.time.Time` object. + - A :class:`~astropy.time.Time` object (NOT :class:`~astropy.time.TimeDelta`). - Another :class:`Timestamp` object, which will result in a copy. @@ -63,6 +63,11 @@ class Timestamp: bytes, sequence or array of any of the former, or None, optional Timestamp, in various formats (if None, defaults to now) + Raises + ------ + ValueError + If `timestamp` is not in a supported format + Attributes ---------- time : :class:`~astropy.time.Time` @@ -87,11 +92,12 @@ class Timestamp: """ def __init__(self, timestamp=None): - format = None if timestamp is None: self.time = Time.now() elif isinstance(timestamp, Timestamp): self.time = timestamp.time.replicate() + elif isinstance(timestamp, TimeDelta): + raise ValueError('Cannot construct Timestamp from TimeDelta {}'.format(timestamp)) elif isinstance(timestamp, Time): self.time = timestamp.replicate() else: From e14526a1c7a06da16c04fffe28fc7a680bdeb282 Mon Sep 17 00:00:00 2001 From: Ludwig Schwardt Date: Thu, 30 Jul 2020 10:36:40 +0200 Subject: [PATCH 047/122] Improve comment We don't need to replicate the internal Time objects because they will be dumped into another Time which will replicate them. --- katpoint/timestamp.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/katpoint/timestamp.py b/katpoint/timestamp.py index 5c174bb..7ea956d 100644 --- a/katpoint/timestamp.py +++ b/katpoint/timestamp.py @@ -103,7 +103,7 @@ def __init__(self, timestamp=None): else: # Convert to array to simplify both array/scalar and string/bytes handling val = np.asarray(timestamp) - # Extract copies of Time objects from inside Timestamps + # Turn array of Timestamps into array of corresponding internal Time objects if val.size > 0 and isinstance(val.flat[0], Timestamp): val = np.vectorize(lambda ts: ts.time)(val) format = None From e9f275b5bb0407cc8873ae8835d4b0c7c081c298 Mon Sep 17 00:00:00 2001 From: Ludwig Schwardt Date: Mon, 20 Jul 2020 15:26:23 +0200 Subject: [PATCH 048/122] Upgrade basic radec frame from FK5 to ICRS There is no reason to still use FK5 with an equinox of J2000.0. All the tests still pass afterwards (maybe they are not strict enough :-D ). Also remove the variable name "epoch" in relation to the date of the FK4 equinoxes, to avoid any confusion. --- katpoint/target.py | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/katpoint/target.py b/katpoint/target.py index 6d40d04..4cdaacb 100644 --- a/katpoint/target.py +++ b/katpoint/target.py @@ -19,7 +19,7 @@ import numpy as np import astropy.units as u from astropy.coordinates import SkyCoord # High-level coordinates -from astropy.coordinates import ICRS, Galactic, FK4, FK5 # Low-level frames +from astropy.coordinates import ICRS, Galactic, FK4 # Low-level frames from astropy.coordinates import Latitude, Longitude # Angles from astropy.time import Time @@ -1034,14 +1034,11 @@ def construct_target_params(description): body.name = "Ra: %s Dec: %s" % (ra, dec) # Extract epoch info from tags if ('B1900' in tags) or ('b1900' in tags): - epoch = Time(1900.0, format='byear') - frame = FK4(equinox=epoch) + frame = FK4(equinox=Time(1900.0, format='byear')) elif ('B1950' in tags) or ('b1950' in tags): - epoch = Time(1950.0, format='byear') - frame = FK4(equinox=epoch) + frame = FK4(equinox=Time(1950.0, format='byear')) else: - epoch = Time(2000.0, format='jyear') - frame = FK5(equinox=epoch) + frame = ICRS body._radec = SkyCoord(ra=ra, dec=dec, frame=frame) elif body_type == 'gal': From 5095ceca39833f21bbf2ce5f62d2e0b2c0b2ebc1 Mon Sep 17 00:00:00 2001 From: Ludwig Schwardt Date: Tue, 21 Jul 2020 22:38:08 +0200 Subject: [PATCH 049/122] Check ICRS (ra, dec) as part of body tests PyEphem actually does geocentric (ra, dec), i.e. something like GCRS, so don't compare against that yet. The new values were obtained from the current test results and verified against direct Astropy calculations. --- katpoint/test/test_body.py | 23 +++++++++++------------ 1 file changed, 11 insertions(+), 12 deletions(-) diff --git a/katpoint/test/test_body.py b/katpoint/test/test_body.py index 62617b1..e967283 100644 --- a/katpoint/test/test_body.py +++ b/katpoint/test/test_body.py @@ -42,19 +42,19 @@ def _get_earth_satellite(): "body, date_str, ra_str, dec_str, az_str, el_str", [ (FixedBody(), '2020-01-01 00:00:00.000', - '10:10:40.123', '40:20:50.567', '326:05:57.541', '51:21:20.0119'), - # 326:05:54.8, 51:21:18.5 (PyEphem) + '10:10:40.123', '40:20:50.567', '326:05:57.541', '51:21:20.0119'), + # 10:10:40.12 40:20:50.6 326:05:54.8, 51:21:18.5 (PyEphem) (Mars(), '2020-01-01 00:00:00.000', - '', '', '118:10:05.1129', '27:23:12.8499'), - # 118:10:06.1, 27:23:13.3 (PyEphem) + '14:05:58.9201', '-12:13:51.9009', '118:10:05.1129', '27:23:12.8499'), + # (PyEphem does GCRS) 118:10:06.1, 27:23:13.3 (PyEphem) (Moon(), '2020-01-01 10:00:00.000', - '', '', '127:15:17.1381', '60:05:10.2438'), - # 127:15:23.6, 60:05:13.7 (PyEphem) + '6:44:11.9332', '23:02:08.402', '127:15:17.1381', '60:05:10.2438'), + # (PyEphem does GCRS) 127:15:23.6, 60:05:13.7 (PyEphem) (Sun(), '2020-01-01 10:00:00.000', - '', '', '234:53:19.4835', '31:38:11.412'), - # 234:53:20.8, 31:38:09.4 (PyEphem) + '7:56:36.8784', '20:53:59.2603', '234:53:19.4835', '31:38:11.412'), + # (PyEphem does GCRS) 234:53:20.8, 31:38:09.4 (PyEphem) (_get_earth_satellite(), '2019-09-23 07:45:36.000', - '3:32:56.7813', '-2:04:35.4329', '280:32:29.675', '-54:06:50.7456'), + '3:32:56.7813', '-2:04:35.4329', '280:32:29.675', '-54:06:50.7456'), # 3:32:59.21 -2:04:36.3 280:32:07.2 -54:06:14.4 (PyEphem) ] ) @@ -71,9 +71,8 @@ def test_compute(body, date_str, ra_str, dec_str, az_str, el_str): height = 4200.0 if isinstance(body, EarthSatellite) else 0.0 body.compute(EarthLocation(lat=lat, lon=lon, height=height), date, 0.0) - if ra_str and dec_str: - assert body.a_radec.ra.to_string(sep=':', unit=u.hour) == ra_str - assert body.a_radec.dec.to_string(sep=':') == dec_str + assert body.a_radec.ra.to_string(sep=':', unit=u.hour) == ra_str + assert body.a_radec.dec.to_string(sep=':') == dec_str assert body.altaz.az.to_string(sep=':') == az_str assert body.altaz.alt.to_string(sep=':') == el_str From 62fc5174de8cb10af9f8056a7136c56506cec312 Mon Sep 17 00:00:00 2001 From: Ludwig Schwardt Date: Wed, 22 Jul 2020 23:57:48 +0200 Subject: [PATCH 050/122] Rework Body classes to ready for vectorisation It would have been nice to ditch Body in favour of Astropy's SkyCoord. The `Body.compute` step could then just be `SkyCoord.transform_to`. There are still some issues, though: - The AltAz frame does not behave like the others. It is necessary to insert obstime and location into the source coord when transforming, even if the target frame already contains it. - SolarSystemBody and EarthSatelliteBody need an ephemeris or propagation step to calculate the position of the body before transforming to the target frame. This needs obstime and probably location too, and these can't be slipped into any empty frame (and SkyCoords can't be empty either, so there goes that idea...). - Converting AltAz to itself introduces subtle numerical errors for no reason, so add a special case for that instead. - The impending array timestamp support will need special handling, since passing an array to obstime currently results in a broadcast error when the coordinate is scalar (as it most likely will be). At least rework `Body.compute` to be more like `SkyCoord.transform_to`. This allows us to compute any coordinate in a body-agnostic way. Don't compute the three major coordinates upfront but calculate them on demand. Store the body coordinate on the `Body` if it is fixed in some standard frame. Otherwise, compute the body coordinates just before transforming them to the target frame. Simplify the SolarSystemBody and remove the individual body classes. The Sun now uses `get_body('Sun', time, location)` instead of `get_sun(time)`, which unifies the treatment of all Solar System objects and returns a more accurate position at the cost of extra computation time. This necessitates a small (fractional arsecond) fix to the test results. Fix NullBody. --- katpoint/bodies.py | 274 +++++++++++------------------------- katpoint/stars.py | 10 +- katpoint/target.py | 160 ++++++++++----------- katpoint/test/test_body.py | 44 +++--- katpoint/test/test_stars.py | 8 +- 5 files changed, 190 insertions(+), 306 deletions(-) diff --git a/katpoint/bodies.py b/katpoint/bodies.py index 8b8ce94..b677e35 100644 --- a/katpoint/bodies.py +++ b/katpoint/bodies.py @@ -14,23 +14,16 @@ # limitations under the License. ################################################################################ -"""Replacement for the pyephem body classes. - -We only implement ra, dec (CIRS), a_ra, a_dec (ICRS RA/dec) and alt, az -(topcentric) as that is all that katpoint uses - -The real pyephem computes observed place but katpoint always sets the -pressure to zero so we compute apparent places instead. -""" +"""A celestial body that can compute its sky position, inspired by PyEphem.""" import copy import datetime import numpy as np import astropy.units as u -from astropy.coordinates import solar_system_ephemeris, get_body, get_sun, get_moon -from astropy.coordinates import CIRS, ICRS, SkyCoord, AltAz from astropy.time import Time, TimeDelta +from astropy.coordinates import ICRS, SkyCoord, AltAz +from astropy.coordinates import solar_system_ephemeris, get_body import sgp4.model import sgp4.earth_gravity @@ -43,184 +36,97 @@ class Body: - """Base class for all Body classes. - - Attributes - ---------- - - a_radec : astropy.coordinates.SkyCoord - Astrometric (ICRS) position at date of observation + """A celestial body that can compute() its sky position. - radec : astropy.coordinates.SkyCoord - Apparent (CIRS) position at date of observation + This is loosely based on PyEphem's `Body` class. It handles both static + coordinates fixed in some standard frame and dynamic coordinates that + are computed on the fly, such as Solar System ephemerides and Earth + satellites. - altaz : astropy.coordinates.AltAz - Topocentric alt/az of body at date of observation + Parameters + ---------- + name : str + Name of celestial body + coord : :class:`~astropy.coordinates.BaseCoordinateFrame` or + :class:`~astropy.coordinates.SkyCoord`, optional + Coordinates of body (None if it is not fixed in any standard frame) """ - def __init__(self): - self._epoch = Time(2000.0, format='jyear') + def __init__(self, name, coord=None): + self.name = name + self.coord = coord - def _compute(self, loc, date, pressure, icrs_radec): - """Calculates the RA/Dec and Az/El of the body. + def compute(self, frame, obstime=None, location=None): + """Compute the coordinates of the body in the requested frame. Parameters ---------- - loc : astropy.coordinates.EarthLocation - Location of observation - - date : astropy.Time - Date of observation - - pressure : float - Atmospheric pressure - - icrs_radec : astropy.coordinates.SkyCoord - Position of the body in the ICRS + frame : str, :class:`~astropy.coordinates.BaseCoordinateFrame` class or + instance, or :class:`~astropy.coordinates.SkyCoord` instance + The frame to transform this body's coordinate into + obstime : :class:`~astropy.time.Time`, optional + The time of observation + location : :class:`~astropy.coordinates.EarthLocation`, optional + The location of the observer on the Earth + + Returns + ------- + coord : :class:`~astropy.coordinates.BaseCoordinateFrame` or + :class:`~astropy.coordinates.SkyCoord` + The computed coordinates as a new object """ - - # Store the astrometric (ICRS) position - self.a_radec = icrs_radec - - # Store apparent (CIRS) position - self.radec = self.a_radec.transform_to(CIRS(obstime=date)) - - # ICRS to Az/El - self.altaz = self.radec.transform_to(AltAz(location=loc, obstime=date, pressure=pressure)) + return self.coord.transform_to(frame) class FixedBody(Body): - """A body with a fixed (on the celestial sphere) position. - - Attributes - ---------- - - _radec : astropy.coordinates.SkyCoord - Position of body in some RA/Dec frame - """ - - def __init__(self): - Body.__init__(self) - - def compute(self, loc, date, pressure): - """Compute alt/az of body. - - Parameters - ---------- - loc : astropy.coordinates.EarthLocation - Location of observation - - date : astropy.Time - Date of observation - - pressure : float - Atmospheric pressure - """ - icrs = self._radec.transform_to(ICRS) - Body._compute(self, loc, date, pressure, icrs) + """A body with a fixed position on the celestial sphere.""" def writedb(self): """ Create an XEphem catalogue entry. See http://www.clearskyinstitute.com/xephem/xephem.html """ - icrs = self._radec.transform_to(ICRS) + icrs = self.coord.transform_to(ICRS) return '{},f,{},{}'.format(self.name, icrs.ra.to_string(sep=':', unit=u.hour), icrs.dec.to_string(sep=':', unit=u.deg)) -class Sun(Body): - def __init__(self): - Body.__init__(self) - self.name = 'Sun' - - def compute(self, loc, date, pressure): - sun = get_sun(date) - icrs = sun.transform_to(ICRS) - Body._compute(self, loc, date, pressure, icrs) - - -class Moon(Body): - def __init__(self): - Body.__init__(self) - self.name = 'Moon' - - def compute(self, loc, date, pressure): - moon = get_moon(date, loc) - icrs = moon.transform_to(ICRS) - Body._compute(self, loc, date, pressure, icrs) - - -class Earth(Body): - def __init__(self): - Body.__init__(self) - - def compute(self): - pass +class SolarSystemBody(Body): + """A major Solar System body identified by name. + Parameters + ---------- + name : str or other + The name of the body (see :func:``~astropy.coordinates.get_body` + for more details). + """ -class Planet(Body): def __init__(self, name): - Body.__init__(self) - self._name = name - - def compute(self, loc, date, pressure): - with solar_system_ephemeris.set('builtin'): - planet = get_body(self._name, date, loc) - icrs = planet.transform_to(ICRS) - Body._compute(self, loc, date, pressure, icrs) + if name.lower() not in solar_system_ephemeris.bodies: + raise ValueError("Unknown Solar System body '{}' - should be one of {}" + .format(name.lower(), solar_system_ephemeris.bodies)) + super().__init__(name, None) - -class Mercury(Planet): - def __init__(self): - Planet.__init__(self, 'mercury') - self.name = 'Mercury' + def compute(self, frame, obstime, location=None): + """Determine position of body in GCRS at given time and transform to `frame`.""" + gcrs = get_body(self.name, obstime, location) + return gcrs.transform_to(frame) -class Venus(Planet): - def __init__(self): - Planet.__init__(self, 'venus') - self.name = 'Venus' +class EarthSatelliteBody(Body): + """Body orbiting the Earth (besides the Moon, which is a SolarSystemBody). + Parameters + ---------- + name : str + Name of body + """ -class Mars(Planet): - def __init__(self): - Planet.__init__(self, 'mars') - self.name = 'Mars' - - -class Jupiter(Planet): - def __init__(self): - Planet.__init__(self, 'jupiter') - self.name = 'Jupiter' - - -class Saturn(Planet): - def __init__(self): - Planet.__init__(self, 'saturn') - self.name = 'Saturn' - - -class Uranus(Planet): - def __init__(self): - Planet.__init__(self, 'uranus') - self.name = 'Uranus' - - -class Neptune(Planet): - def __init__(self): - Planet.__init__(self, 'neptune') - self.name = 'Neptune' - - -class EarthSatellite(Body): - """Body orbiting the earth.""" - - def __init__(self): - Body.__init__(self) - - def compute(self, loc, date, pressure): + def __init__(self, name): + super().__init__(name, None) + def compute(self, frame, obstime, location): + """Determine position of body at the given time and transform to `frame`.""" # Create an SGP4 satellite object self._sat = sgp4.model.Satellite() self._sat.whichconst = sgp4.earth_gravity.wgs84 @@ -249,7 +155,7 @@ def compute(self, loc, date, pressure): self._sat.no_kozai = self._n / (24.0 * 60.0) * (2.0 * np.pi) # Compute position and velocity - date = date.iso + date = obstime.iso yr = int(date[:4]) mon = int(date[5:7]) day = int(date[8:10]) @@ -271,11 +177,11 @@ def compute(self, loc, date, pressure): # Convert to alt, az at observer az, alt = get_observer_look(lon, lat, alt, utc_time, - loc.lon.deg, loc.lat.deg, loc.height.to(u.kilometer).value) + location.lon.deg, location.lat.deg, + location.height.to(u.kilometer).value) - self.altaz = SkyCoord(az*u.deg, alt*u.deg, location=loc, - obstime=date, pressure=pressure, frame=AltAz) - self.a_radec = self.altaz.transform_to(ICRS) + altaz = SkyCoord(az*u.deg, alt*u.deg, frame=AltAz, obstime=obstime, location=location) + return altaz.transform_to(frame) def writedb(self): """ Create an XEphem catalogue entry. @@ -338,7 +244,7 @@ def _tle_to_float(tle_float): def readtle(name, line1, line2): - """Create an EarthSatellite object from a TLE description of an orbit. + """Create an EarthSatelliteBody object from a TLE description of an orbit. See https://en.wikipedia.org/wiki/Two-line_element_set @@ -355,8 +261,7 @@ def readtle(name, line1, line2): """ line1 = line1.lstrip() line2 = line2.lstrip() - s = EarthSatellite() - s.name = name + s = EarthSatelliteBody(name) epochyr = int('20' + line1[18:20]) epochdays = float(line1[20:32]) @@ -463,28 +368,21 @@ class StationaryBody(Body): """ def __init__(self, az, el, name=None): - self._azel = AltAz(az=angle_from_degrees(az), alt=angle_from_degrees(el)) - if not name: - name = "Az: {} El: {}".format(self._azel.az.to_string(sep=':', unit=u.deg), - self._azel.alt.to_string(sep=':', unit=u.deg)) - self.name = name + super().__init__(name, AltAz(az=angle_from_degrees(az), alt=angle_from_degrees(el))) + if not self.name: + self.name = "Az: {} El: {}".format(self.coord.az.to_string(sep=':', unit=u.deg), + self.coord.alt.to_string(sep=':', unit=u.deg)) - def compute(self, loc, date, pressure): - """Update target coordinates for given observer. + def compute(self, frame, obstime, location): + """Transform (az, el) at given location and time to requested `frame`.""" + altaz = self.coord.replicate(obstime=obstime, location=location) + if isinstance(frame, AltAz) and altaz.is_equivalent_frame(frame): + return altaz + else: + return altaz.transform_to(frame) - This updates the (ra, dec) coordinates of the target, as seen from the - given *observer*, while its (az, el) coordinates remain unchanged. - """ - self.altaz = AltAz(az=self._azel.az, alt=self._azel.alt, - location=loc, obstime=date, pressure=pressure) - # Store the astrometric (ICRS) position - self.a_radec = self.altaz.transform_to(ICRS) - # Store apparent (CIRS) position - self.radec = self.a_radec.transform_to(CIRS(obstime=date)) - - -class NullBody: +class NullBody(Body): """Body with no position, used as a placeholder. This body has the expected methods of :class:`Body`, but always returns NaNs @@ -493,10 +391,4 @@ class NullBody: """ def __init__(self): - self.name = 'Nothing' - self.altaz = None - self.a_radec = None - self.radec = None - - def compute(self, loc, date, pressure): - pass + super().__init__('Nothing', ICRS(np.nan * u.rad, np.nan * u.rad)) diff --git a/katpoint/stars.py b/katpoint/stars.py index 8ddd131..f3402be 100644 --- a/katpoint/stars.py +++ b/katpoint/stars.py @@ -32,7 +32,7 @@ from astropy.coordinates import SkyCoord, Longitude, Latitude, ICRS from astropy.time import Time -from katpoint.bodies import FixedBody, EarthSatellite +from katpoint.bodies import FixedBody, EarthSatelliteBody db = """\ @@ -171,12 +171,9 @@ def readdb(line): name = fields[0] ra = fields[2].split('|')[0] dec = fields[3].split('|')[0] - s = FixedBody() - s.name = name ra = Longitude(ra, unit=u.hour) dec = Latitude(dec, unit=u.deg) - s._radec = SkyCoord(ra=ra, dec=dec, frame=ICRS) - return s + return FixedBody(name, SkyCoord(ra=ra, dec=dec, frame=ICRS)) elif fields[1][0] == 'E': @@ -184,8 +181,7 @@ def readdb(line): subfields = fields[2].split('|') # This is an earth satellite. - e = EarthSatellite() - e.name = fields[0] + e = EarthSatelliteBody(name=fields[0]) epoch = subfields[0].split('/') yr = epoch[2] mon = epoch[0] diff --git a/katpoint/target.py b/katpoint/target.py index 4cdaacb..b3ecd99 100644 --- a/katpoint/target.py +++ b/katpoint/target.py @@ -19,7 +19,7 @@ import numpy as np import astropy.units as u from astropy.coordinates import SkyCoord # High-level coordinates -from astropy.coordinates import ICRS, Galactic, FK4 # Low-level frames +from astropy.coordinates import ICRS, Galactic, FK4, AltAz, CIRS # Low-level frames from astropy.coordinates import Latitude, Longitude # Angles from astropy.time import Time @@ -28,8 +28,7 @@ from .ephem_extra import (is_iterable, lightspeed, deg2rad, angle_from_degrees, angle_from_hours) from .conversion import azel_to_enu from .projection import sphere_to_plane, sphere_to_ortho, plane_to_sphere -from . import bodies -from .bodies import FixedBody, readtle, StationaryBody, NullBody +from .bodies import FixedBody, readtle, StationaryBody, SolarSystemBody, NullBody from .stars import star, readdb @@ -141,13 +140,13 @@ def __str__(self): descr += ' (%s)' % (', '.join(self.aliases),) descr += ', tags=%s' % (' '.join(self.tags),) if 'radec' in self.tags: - descr += ', %s %s' % (self.body._radec.ra.to_string(unit=u.hour), - self.body._radec.dec.to_string(unit=u.deg)) + descr += ', %s %s' % (self.body.coord.ra.to_string(unit=u.hour), + self.body.coord.dec.to_string(unit=u.deg)) if self.body_type == 'azel': - descr += ', %s %s' % (self.body._azel.az.to_string(unit=u.deg), - self.body._azel.alt.to_string(unit=u.deg)) + descr += ', %s %s' % (self.body.coord.az.to_string(unit=u.deg), + self.body.coord.alt.to_string(unit=u.deg)) if self.body_type == 'gal': - gal = self.body._radec.transform_to(Galactic) + gal = self.body.compute(Galactic) descr += ', %.4f %.4f' % (gal.l.deg, gal.b.deg) if self.flux_model is None: descr += ', no flux info' @@ -213,13 +212,15 @@ def _set_timestamp_antenna_defaults(self, timestamp, antenna): ValueError If no antenna is specified, and no default antenna was set either """ - if timestamp is None: - timestamp = Timestamp() if antenna is None: antenna = self.antenna if antenna is None: raise ValueError('Antenna object needed to calculate target position') - return timestamp, antenna + if is_iterable(timestamp): + time = [Timestamp(t).time for t in timestamp] + else: + time = Timestamp(timestamp).time + return time, antenna @property def body_type(self): @@ -237,8 +238,8 @@ def description(self): # Check if it's an unnamed target with a default name if names.startswith('Az:'): fields = [tags] - fields += [self.body._azel.az.to_string(unit=u.deg), - self.body._azel.alt.to_string(unit=u.deg)] + fields += [self.body.coord.az.to_string(unit=u.deg), + self.body.coord.alt.to_string(unit=u.deg)] if fluxinfo: fields += [fluxinfo] @@ -246,8 +247,8 @@ def description(self): # Check if it's an unnamed target with a default name if names.startswith('Ra:'): fields = [tags] - fields += [self.body._radec.dec.to_string(unit=u.hour), - self.body._radec.dec.to_string(unit=u.deg)] + fields += [self.body.coord.ra.to_string(unit=u.hour), + self.body.coord.dec.to_string(unit=u.deg)] if fluxinfo: fields += [fluxinfo] @@ -255,7 +256,7 @@ def description(self): # Check if it's an unnamed target with a default name if names.startswith('Galactic l:'): fields = [tags] - gal = self.body._radec.transform_to(Galactic) + gal = self.body.compute(Galactic) fields += ['%.4f' % (gal.l.deg,), '%.4f' % (gal.b.deg,)] if fluxinfo: fields += [fluxinfo] @@ -332,18 +333,18 @@ def azel(self, timestamp=None, antenna=None): ValueError If no antenna is specified, and no default antenna was set either """ - timestamp, antenna = self._set_timestamp_antenna_defaults(timestamp, antenna) + time, antenna = self._set_timestamp_antenna_defaults(timestamp, antenna) + location = antenna.earth_location def _scalar_azel(t): """Calculate (az, el) coordinates for a single time instant.""" - self.body.compute(antenna.earth_location, - Timestamp(t).time, antenna.pressure) - return self.body.altaz - if is_iterable(timestamp): - azel = np.array([_scalar_azel(t) for t in timestamp], dtype=object) + altaz = AltAz(obstime=t, location=location) + return self.body.compute(altaz, obstime=t, location=location) + if is_iterable(time): + azel = np.array([_scalar_azel(t) for t in time], dtype=object) return azel else: - return _scalar_azel(timestamp) + return _scalar_azel(time) def apparent_radec(self, timestamp=None, antenna=None): """Calculate target's apparent (ra, dec) coordinates as seen from antenna at time(s). @@ -374,18 +375,17 @@ def apparent_radec(self, timestamp=None, antenna=None): ValueError If no antenna is specified, and no default antenna was set either """ - timestamp, antenna = self._set_timestamp_antenna_defaults(timestamp, antenna) + time, antenna = self._set_timestamp_antenna_defaults(timestamp, antenna) + location = antenna.earth_location def _scalar_radec(t): - """Calculate (ra, dec) coordinates for a single time instant.""" - date = Timestamp(t).time - self.body.compute(antenna.earth_location, date, antenna.pressure) - return self.body.radec - if is_iterable(timestamp): - radec = np.array([_scalar_radec(t) for t in timestamp]) + """Calculate CIRS (ra, dec) coordinates for a single time instant.""" + return self.body.compute(CIRS(obstime=t), obstime=t, location=location) + if is_iterable(time): + radec = np.array([_scalar_radec(t) for t in time]) return radec[:, 0], radec[:, 1] else: - return _scalar_radec(timestamp) + return _scalar_radec(time) def astrometric_radec(self, timestamp=None, antenna=None): """Calculate target's astrometric (ra, dec) coordinates as seen from antenna at time(s). @@ -414,24 +414,22 @@ def astrometric_radec(self, timestamp=None, antenna=None): If no antenna is specified, and no default antenna was set either """ if self.body_type == 'radec': - radec = self.body._radec.transform_to(ICRS) + radec = self.body.compute(ICRS) if is_iterable(timestamp): return np.tile(radec, len(timestamp)) else: return radec - timestamp, antenna = self._set_timestamp_antenna_defaults(timestamp, antenna) + time, antenna = self._set_timestamp_antenna_defaults(timestamp, antenna) + location = antenna.earth_location def _scalar_radec(t): - """Calculate (ra, dec) coordinates for a single time instant.""" - date = Timestamp(t).time - self.body.compute(antenna.earth_location, date, antenna.pressure) - - return self.body.a_radec - if is_iterable(timestamp): - radec = np.array([_scalar_radec(t) for t in timestamp]) + """Calculate ICRS (ra, dec) coordinates for a single time instant.""" + return self.body.compute(ICRS, obstime=t, location=location) + if is_iterable(time): + radec = np.array([_scalar_radec(t) for t in time]) return radec else: - return _scalar_radec(timestamp) + return _scalar_radec(time) # The default (ra, dec) coordinates are the astrometric ones radec = astrometric_radec @@ -463,7 +461,7 @@ def galactic(self, timestamp=None, antenna=None): If no antenna is specified, and no default antenna was set either """ if self.body_type == 'gal': - gal = self.body._radec.transform_to(Galactic) + gal = self.body.compute(Galactic) if is_iterable(timestamp): return np.tile(gal.l, len(timestamp)), np.tile(gal.b, len(timestamp)) else: @@ -514,12 +512,13 @@ def parallactic_angle(self, timestamp=None, antenna=None): .. _`AIPS++ Glossary`: http://www.astron.nl/aips++/docs/glossary/p.html .. _`Starlink Project`: http://www.starlink.rl.ac.uk """ - timestamp, antenna = self._set_timestamp_antenna_defaults(timestamp, antenna) + time, antenna = self._set_timestamp_antenna_defaults(timestamp, antenna) + location = antenna.earth_location # Get apparent hour angle and declination - radec = self.apparent_radec(timestamp, antenna) - ha = antenna.local_sidereal_time(timestamp) - radec.ra + radec = self.apparent_radec(time, antenna) + ha = antenna.local_sidereal_time(time) - radec.ra return np.arctan2(np.sin(ha), - np.tan(antenna.earth_location.lat.rad) * np.cos(radec.dec) + np.tan(location.lat.rad) * np.cos(radec.dec) - np.sin(radec.dec) * np.cos(ha)) def geometric_delay(self, antenna2, timestamp=None, antenna=None): @@ -564,11 +563,11 @@ def geometric_delay(self, antenna2, timestamp=None, antenna=None): pointing from the reference antenna to the second antenna, all in local ENU coordinates relative to the reference antenna. """ - timestamp, antenna = self._set_timestamp_antenna_defaults(timestamp, antenna) + time, antenna = self._set_timestamp_antenna_defaults(timestamp, antenna) # Obtain baseline vector from reference antenna to second antenna baseline_m = antenna.baseline_toward(antenna2) # Obtain direction vector(s) from reference antenna to target - azel = self.azel(timestamp, antenna) + azel = self.azel(time, antenna) if is_iterable(azel): az = np.array([i.az.rad for i in azel]) el = np.array([i.alt.rad for i in azel]) @@ -579,7 +578,7 @@ def geometric_delay(self, antenna2, timestamp=None, antenna=None): # Dot product of vectors is w coordinate, and delay is time taken by EM wave to traverse this delay = - np.dot(baseline_m, targetdir) / lightspeed # Numerically estimate delay rate from difference across 1-second interval spanning timestamp(s) - azel = self.azel(np.array(timestamp) - 0.5, antenna) + azel = self.azel(np.array(time) - 0.5 * u.s.to(u.day), antenna) if is_iterable(azel): az = np.array([i.az.rad for i in azel]) el = np.array([i.alt.rad for i in azel]) @@ -587,7 +586,7 @@ def geometric_delay(self, antenna2, timestamp=None, antenna=None): az = azel.az.rad el = azel.alt.rad targetdir_before = azel_to_enu(az, el) - azel = self.azel(np.array(timestamp) + 0.5, antenna) + azel = self.azel(np.array(time) + 0.5 * u.s.to(u.day), antenna) if is_iterable(azel): az = np.array([i.az.rad for i in azel]) el = np.array([i.alt.rad for i in azel]) @@ -626,11 +625,11 @@ def uvw_basis(self, timestamp=None, antenna=None): the first two dimensions correspond to the matrix and the final dimension to the timestamp. """ - timestamp, antenna = self._set_timestamp_antenna_defaults(timestamp, antenna) - if is_iterable(timestamp) and self.body_type != 'radec': + time, antenna = self._set_timestamp_antenna_defaults(timestamp, antenna) + if is_iterable(time) and self.body_type != 'radec': # Some calculations depend on ra/dec in a way that won't easily # vectorise. - bases = [self.uvw_basis(t, antenna) for t in timestamp] + bases = [self.uvw_basis(t, antenna) for t in time] return np.stack(bases, axis=-1) # Offset the target slightly in declination to approximate the @@ -644,18 +643,18 @@ def uvw_basis(self, timestamp=None, antenna=None): # single precision and this method suffers from loss of precision. # 0.03 was found by experimentation (albeit on a single data set) to # to be large enough to avoid the numeric instability. - if is_iterable(timestamp): + if is_iterable(time): # Due to the test above, this is a radec target and so timestamp # doesn't matter. But we want a scalar. radec = self.radec(None, antenna) else: - radec = self.radec(timestamp, antenna) + radec = self.radec(time, antenna) offset_sign = -1 if radec.dec > 0 else 1 offset = construct_radec_target(radec.ra.rad, radec.dec.rad + 0.03 * offset_sign) # Get offset az-el vector at current epoch pointed to by reference antenna - offset_azel = offset.azel(timestamp, antenna) + offset_azel = offset.azel(time, antenna) # Obtain direction vector(s) from reference antenna to target - azel = self.azel(timestamp, antenna) + azel = self.azel(time, antenna) if type(azel) == np.ndarray: az = np.array([a.az.rad for a in azel]) el = np.array([a.alt.rad for a in azel]) @@ -714,9 +713,9 @@ def uvw(self, antenna2, timestamp=None, antenna=None): This avoids having to convert (az, el) angles to (ha, dec) angles and uses linear algebra throughout instead. """ - timestamp, antenna = self._set_timestamp_antenna_defaults(timestamp, antenna) + time, antenna = self._set_timestamp_antenna_defaults(timestamp, antenna) # Obtain basis vectors - basis = self.uvw_basis(timestamp, antenna) + basis = self.uvw_basis(time, antenna) # Obtain baseline vector from reference antenna to second antenna if is_iterable(antenna2): baseline_m = np.stack([antenna.baseline_toward(a2) for a2 in antenna2]) @@ -856,17 +855,17 @@ def separation(self, other_target, timestamp=None, antenna=None): time and finds the angular distance between the two sets of coordinates. """ # Get a common timestamp and antenna for both targets - timestamp, antenna = self._set_timestamp_antenna_defaults(timestamp, antenna) + time, antenna = self._set_timestamp_antenna_defaults(timestamp, antenna) def _scalar_separation(t): """Calculate angular separation for a single time instant.""" azel1 = self.azel(t, antenna) azel2 = other_target.azel(t, antenna) return azel1.separation(azel2).rad - if is_iterable(timestamp): - return np.array([_scalar_separation(t) for t in timestamp]) + if is_iterable(time): + return np.array([_scalar_separation(t) for t in time]) else: - return _scalar_separation(timestamp) + return _scalar_separation(time) def sphere_to_plane(self, az, el, timestamp=None, antenna=None, projection_type='ARC', coord_system='azel'): """Project spherical coordinates to plane with target position as reference. @@ -1022,16 +1021,13 @@ def construct_target_params(description): if len(fields) < 4: raise ValueError("Target description '%s' contains *radec* body with no (ra, dec) coordinates" % description) - body = FixedBody() try: ra = deg2rad(float(fields[2])) except ValueError: ra = fields[2] ra, dec = angle_from_hours(ra), angle_from_degrees(fields[3]) - if preferred_name: - body.name = preferred_name - else: - body.name = "Ra: %s Dec: %s" % (ra, dec) + if not preferred_name: + preferred_name = "Ra: %s Dec: %s" % (ra, dec) # Extract epoch info from tags if ('B1900' in tags) or ('b1900' in tags): frame = FK4(equinox=Time(1900.0, format='byear')) @@ -1039,20 +1035,17 @@ def construct_target_params(description): frame = FK4(equinox=Time(1950.0, format='byear')) else: frame = ICRS - body._radec = SkyCoord(ra=ra, dec=dec, frame=frame) + body = FixedBody(preferred_name, SkyCoord(ra=ra, dec=dec, frame=frame)) elif body_type == 'gal': if len(fields) < 4: raise ValueError("Target description '%s' contains *gal* body with no (l, b) coordinates" % description) l, b = float(fields[2]), float(fields[3]) - body = FixedBody() - if preferred_name: - body.name = preferred_name - else: - body.name = "Galactic l: %.4f b: %.4f" % (l, b) - body._epoch = Time(2000.0, format='jyear') - body._radec = SkyCoord(l=Longitude(l, unit=u.deg), b=Latitude(b, unit=u.deg), frame=Galactic) + if not preferred_name: + preferred_name = "Galactic l: %.4f b: %.4f" % (l, b) + body = FixedBody(preferred_name, SkyCoord(l=Longitude(l, unit=u.deg), + b=Latitude(b, unit=u.deg), frame=Galactic)) elif body_type == 'tle': lines = fields[-1].split('\n') @@ -1070,12 +1063,14 @@ def construct_target_params(description): raise ValueError("Target description '%s' contains malformed *tle* body" % description) elif body_type == 'special': - special_name = preferred_name.capitalize() try: - body = getattr(bodies, special_name)() if special_name != 'Nothing' else NullBody() - except AttributeError: + if preferred_name.capitalize() != 'Nothing': + body = SolarSystemBody(preferred_name) + else: + body = NullBody() + except ValueError as err: raise ValueError("Target description '%s' contains unknown *special* body '%s'" - % (description, special_name)) + % (description, preferred_name)) from err elif body_type == 'star': star_name = ' '.join([w.capitalize() for w in preferred_name.split()]) @@ -1171,7 +1166,6 @@ def construct_radec_target(ra, dec): target : :class:`Target` object Constructed target object """ - body = FixedBody() # First try to interpret the string as decimal degrees if isinstance(ra, str): try: @@ -1179,6 +1173,6 @@ def construct_radec_target(ra, dec): except ValueError: pass ra, dec = angle_from_hours(ra), angle_from_degrees(dec) - body.name = "Ra: %s Dec: %s" % (ra, dec) - body._radec = SkyCoord(ra=ra, dec=dec, frame=ICRS) + name = "Ra: %s Dec: %s" % (ra, dec) + body = FixedBody(name, SkyCoord(ra=ra, dec=dec, frame=ICRS)) return Target(body, 'radec') diff --git a/katpoint/test/test_body.py b/katpoint/test/test_body.py index e967283..ab8d885 100644 --- a/katpoint/test/test_body.py +++ b/katpoint/test/test_body.py @@ -25,10 +25,16 @@ import numpy as np from numpy.testing import assert_allclose import astropy.units as u -from astropy.coordinates import SkyCoord, ICRS, EarthLocation, Latitude, Longitude +from astropy.coordinates import SkyCoord, ICRS, AltAz, EarthLocation, Latitude, Longitude from astropy.time import Time -from katpoint.bodies import FixedBody, Sun, Moon, Mars, EarthSatellite, readtle +from katpoint.bodies import FixedBody, SolarSystemBody, EarthSatelliteBody, readtle + + +def _get_fixed_body(ra_str, dec_str): + ra = Longitude(ra_str, unit=u.hour) + dec = Latitude(dec_str, unit=u.deg) + return FixedBody('name', SkyCoord(ra=ra, dec=dec, frame=ICRS)) def _get_earth_satellite(): @@ -41,17 +47,17 @@ def _get_earth_satellite(): @pytest.mark.parametrize( "body, date_str, ra_str, dec_str, az_str, el_str", [ - (FixedBody(), '2020-01-01 00:00:00.000', + (_get_fixed_body('10:10:40.123', '40:20:50.567'), '2020-01-01 00:00:00.000', '10:10:40.123', '40:20:50.567', '326:05:57.541', '51:21:20.0119'), # 10:10:40.12 40:20:50.6 326:05:54.8, 51:21:18.5 (PyEphem) - (Mars(), '2020-01-01 00:00:00.000', + (SolarSystemBody('Mars'), '2020-01-01 00:00:00.000', '14:05:58.9201', '-12:13:51.9009', '118:10:05.1129', '27:23:12.8499'), # (PyEphem does GCRS) 118:10:06.1, 27:23:13.3 (PyEphem) - (Moon(), '2020-01-01 10:00:00.000', + (SolarSystemBody('Moon'), '2020-01-01 10:00:00.000', '6:44:11.9332', '23:02:08.402', '127:15:17.1381', '60:05:10.2438'), # (PyEphem does GCRS) 127:15:23.6, 60:05:13.7 (PyEphem) - (Sun(), '2020-01-01 10:00:00.000', - '7:56:36.8784', '20:53:59.2603', '234:53:19.4835', '31:38:11.412'), + (SolarSystemBody('Sun'), '2020-01-01 10:00:00.000', + '7:56:36.7964', '20:53:59.4553', '234:53:19.4762', '31:38:11.4248'), # (PyEphem does GCRS) 234:53:20.8, 31:38:09.4 (PyEphem) (_get_earth_satellite(), '2019-09-23 07:45:36.000', '3:32:56.7813', '-2:04:35.4329', '280:32:29.675', '-54:06:50.7456'), @@ -60,26 +66,22 @@ def _get_earth_satellite(): ) def test_compute(body, date_str, ra_str, dec_str, az_str, el_str): """Test compute method""" + obstime = Time(date_str) lat = Latitude('10:00:00.000', unit=u.deg) lon = Longitude('80:00:00.000', unit=u.deg) - date = Time(date_str) - - if isinstance(body, FixedBody): - ra = Longitude(ra_str, unit=u.hour) - dec = Latitude(dec_str, unit=u.deg) - body._radec = SkyCoord(ra=ra, dec=dec, frame=ICRS) - height = 4200.0 if isinstance(body, EarthSatellite) else 0.0 - body.compute(EarthLocation(lat=lat, lon=lon, height=height), date, 0.0) - - assert body.a_radec.ra.to_string(sep=':', unit=u.hour) == ra_str - assert body.a_radec.dec.to_string(sep=':') == dec_str - assert body.altaz.az.to_string(sep=':') == az_str - assert body.altaz.alt.to_string(sep=':') == el_str + height = 4200.0 if isinstance(body, EarthSatelliteBody) else 0.0 + location = EarthLocation(lat=lat, lon=lon, height=height) + radec = body.compute(ICRS, obstime, location) + assert radec.ra.to_string(sep=':', unit=u.hour) == ra_str + assert radec.dec.to_string(sep=':') == dec_str + altaz = body.compute(AltAz(obstime=obstime, location=location), obstime, location) + assert altaz.az.to_string(sep=':') == az_str + assert altaz.alt.to_string(sep=':') == el_str def test_earth_satellite(): sat = _get_earth_satellite() - # Check that the EarthSatellite object has the expect attribute values. + # Check that the EarthSatelliteBody object has the expected attribute values assert str(sat._epoch) == '2019-09-23 07:45:35.842' assert sat._inc == np.deg2rad(55.4408) assert sat._raan == np.deg2rad(61.3790) diff --git a/katpoint/test/test_stars.py b/katpoint/test/test_stars.py index ba10b9e..3862581 100644 --- a/katpoint/test/test_stars.py +++ b/katpoint/test/test_stars.py @@ -19,7 +19,7 @@ import numpy as np from katpoint.stars import readdb -from katpoint.bodies import EarthSatellite, FixedBody +from katpoint.bodies import EarthSatelliteBody, FixedBody def test_earth_satellite(): @@ -27,7 +27,7 @@ def test_earth_satellite(): '55.4408,61.379002,0.0191986,78.180199,283.9935,2.0056172,1.2e-07,10428,9.9999997e-05' e = readdb(record) - assert isinstance(e, EarthSatellite) + assert isinstance(e, EarthSatelliteBody) assert e.name == 'GPS BIIA-21 (PR' assert str(e._epoch) == '2019-09-23 07:45:35.842' assert e._inc == np.deg2rad(55.4408) @@ -46,5 +46,5 @@ def test_star(): e = readdb(record) assert isinstance(e, FixedBody) assert e.name == 'Sadr' - assert e._radec.ra.to_string(sep=':', unit='hour') == '20:22:13.7' - assert e._radec.dec.to_string(sep=':', unit='deg') == '40:15:24' + assert e.coord.ra.to_string(sep=':', unit='hour') == '20:22:13.7' + assert e.coord.dec.to_string(sep=':', unit='deg') == '40:15:24' From e5898f243bb616f6379cbc4a628915c62bde155a Mon Sep 17 00:00:00 2001 From: Ludwig Schwardt Date: Fri, 24 Jul 2020 23:50:57 +0200 Subject: [PATCH 051/122] Rename bodies module to body This fits the naming scheme better in singular form. Although it would have been nicer to make a "let the bodies hit the floor" meme with the old name. --- katpoint/{bodies.py => body.py} | 0 katpoint/stars.py | 2 +- katpoint/target.py | 2 +- katpoint/test/test_body.py | 2 +- katpoint/test/test_stars.py | 2 +- 5 files changed, 4 insertions(+), 4 deletions(-) rename katpoint/{bodies.py => body.py} (100%) diff --git a/katpoint/bodies.py b/katpoint/body.py similarity index 100% rename from katpoint/bodies.py rename to katpoint/body.py diff --git a/katpoint/stars.py b/katpoint/stars.py index f3402be..b08ac00 100644 --- a/katpoint/stars.py +++ b/katpoint/stars.py @@ -32,7 +32,7 @@ from astropy.coordinates import SkyCoord, Longitude, Latitude, ICRS from astropy.time import Time -from katpoint.bodies import FixedBody, EarthSatelliteBody +from katpoint.body import FixedBody, EarthSatelliteBody db = """\ diff --git a/katpoint/target.py b/katpoint/target.py index b3ecd99..27d0934 100644 --- a/katpoint/target.py +++ b/katpoint/target.py @@ -28,7 +28,7 @@ from .ephem_extra import (is_iterable, lightspeed, deg2rad, angle_from_degrees, angle_from_hours) from .conversion import azel_to_enu from .projection import sphere_to_plane, sphere_to_ortho, plane_to_sphere -from .bodies import FixedBody, readtle, StationaryBody, SolarSystemBody, NullBody +from .body import FixedBody, readtle, StationaryBody, SolarSystemBody, NullBody from .stars import star, readdb diff --git a/katpoint/test/test_body.py b/katpoint/test/test_body.py index ab8d885..af82859 100644 --- a/katpoint/test/test_body.py +++ b/katpoint/test/test_body.py @@ -28,7 +28,7 @@ from astropy.coordinates import SkyCoord, ICRS, AltAz, EarthLocation, Latitude, Longitude from astropy.time import Time -from katpoint.bodies import FixedBody, SolarSystemBody, EarthSatelliteBody, readtle +from katpoint.body import FixedBody, SolarSystemBody, EarthSatelliteBody, readtle def _get_fixed_body(ra_str, dec_str): diff --git a/katpoint/test/test_stars.py b/katpoint/test/test_stars.py index 3862581..fe33a2b 100644 --- a/katpoint/test/test_stars.py +++ b/katpoint/test/test_stars.py @@ -19,7 +19,7 @@ import numpy as np from katpoint.stars import readdb -from katpoint.bodies import EarthSatelliteBody, FixedBody +from katpoint.body import EarthSatelliteBody, FixedBody def test_earth_satellite(): From 5987435ea7d4cb070d25185e42e40ad53cd0809c Mon Sep 17 00:00:00 2001 From: Ludwig Schwardt Date: Tue, 28 Jul 2020 12:23:41 +0200 Subject: [PATCH 052/122] Broadcast AltAz coordinates in compute() In order to compute positions with AltAz, the frame needs to be complete with obstime and location. Adding these attributes directly to the internal coord fails, because the scalar (az, el) values won't broadcast automatically. Therefore first repeat these coordinates to have the same shape as `obstime` (normally the source of vectorisation) before replication. Also add a comment to explain why we short-circuit the transform_to in the case of AltAz->AltAz transformation. --- katpoint/body.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/katpoint/body.py b/katpoint/body.py index b677e35..5f9a9ae 100644 --- a/katpoint/body.py +++ b/katpoint/body.py @@ -375,7 +375,12 @@ def __init__(self, az, el, name=None): def compute(self, frame, obstime, location): """Transform (az, el) at given location and time to requested `frame`.""" - altaz = self.coord.replicate(obstime=obstime, location=location) + # Ensure that coordinates have same shape as obstime (broadcasting fails) + altaz = self.coord.take(np.zeros_like(obstime, dtype=int)) + altaz = altaz.replicate(obstime=obstime, location=location) + # Bypass transform_to for AltAz -> AltAz, otherwise we need a location + # and the output (az, el) will not be exactly equal to the coord (az, el) + # due to small numerical errors. if isinstance(frame, AltAz) and altaz.is_equivalent_frame(frame): return altaz else: From d666eef472225e4263998034a4b6b1a4c2b8b7ba Mon Sep 17 00:00:00 2001 From: Ludwig Schwardt Date: Tue, 28 Jul 2020 12:30:46 +0200 Subject: [PATCH 053/122] Vectorise all the major Target methods Instead of returning a list of Astropy frames or angles, let the sequence live inside the Astropy object instead. This finally gets rid of the PyEphem-driven scalar internal functions and is_iterable calls that dotted the Target class. Rework the `_set_timestamp_antenna_defaults` method. Timestamp parameters are now simply laundered through the `Timestamp` class to obtain the corresponding (vectorised) Astropy `Time` object. We now only need to handle antenna defaults. The error handling for this is still a work in progress. `Target.separation` now returns an `Angle` instead of floats in radians (yay, effectively back to ephem.Angles!). `Target.galactic` now uses `FixedBody` directly instead of transforming ICRS coordinates. `Target.geometric_delay` is simplified and sped up a lot by obtaining (az, el) for all timestamps in one go (and culling array checks). Improve docstrings where possible, but this only highlighted that the radec methods are quite different from their original definitions. More work needed... This addresses JIRA ticket SPAZA-87. --- katpoint/antenna.py | 23 +-- katpoint/catalogue.py | 5 +- katpoint/target.py | 284 ++++++++++++----------------------- katpoint/test/test_target.py | 6 +- 4 files changed, 111 insertions(+), 207 deletions(-) diff --git a/katpoint/antenna.py b/katpoint/antenna.py index 6943c5f..3da96c9 100644 --- a/katpoint/antenna.py +++ b/katpoint/antenna.py @@ -24,10 +24,8 @@ import numpy as np import astropy.units as u from astropy.coordinates import Latitude, Longitude, EarthLocation -from astropy.time import Time from .timestamp import Timestamp -from .ephem_extra import is_iterable from .conversion import enu_to_ecef, ecef_to_lla, lla_to_ecef, ecef_to_enu from .pointing import PointingModel from .delay import DelayModel @@ -323,28 +321,21 @@ def baseline_toward(self, antenna2): def local_sidereal_time(self, timestamp=None): """Calculate local sidereal time at antenna for timestamp(s). - This is a vectorised function that returns the local sidereal time at - the antenna for a given UTC timestamp. + This is a vectorised function that returns the local apparent sidereal + time at the antenna for a given UTC timestamp. Parameters ---------- - timestamp : :class:`Timestamp` object or equivalent, or sequence, optional + timestamp : :class:`Timestamp` object or equivalent, optional Timestamp(s) in UTC seconds since Unix epoch (defaults to now) Returns ------- - lst : :class:`astropy.coordinates.Angle` object, or sequence of objects - Local sidereal time(s), in radians + last : :class:`astropy.coordinates.Longitude` + Local apparent sidereal time(s) """ - def _scalar_local_sidereal_time(t): - """Calculate local sidereal time at a single time instant.""" - time = Time(Timestamp(t).time, location=self.earth_location) - return time.sidereal_time('apparent') - - if is_iterable(timestamp): - return np.array([_scalar_local_sidereal_time(t) for t in timestamp], dtype=object) - else: - return _scalar_local_sidereal_time(timestamp) + time = Timestamp(timestamp).time + return time.sidereal_time('apparent', longitude=self.earth_location.lon) def array_reference_antenna(self, name='array'): """Synthetic antenna at the delay model reference position of this antenna. diff --git a/katpoint/catalogue.py b/katpoint/catalogue.py index 937d7e2..3943cb4 100644 --- a/katpoint/catalogue.py +++ b/katpoint/catalogue.py @@ -24,7 +24,6 @@ from .target import Target from .timestamp import Timestamp -from .ephem_extra import rad2deg from .stars import stars logger = logging.getLogger(__name__) @@ -623,7 +622,7 @@ def closest_to(self, target, timestamp=None, antenna=None): """ if len(self.targets) == 0: return None, 180.0 - dist = rad2deg(np.array([target.separation(tgt, timestamp, antenna) for tgt in self.targets])) + dist = np.array([target.separation(tgt, timestamp, antenna).deg for tgt in self.targets]) closest = dist.argmin() return self.targets[closest], dist[closest] @@ -757,7 +756,7 @@ def iterfilter(self, tags=None, flux_limit_Jy=None, flux_freq_MHz=None, az_limit if (el_deg < el_limit_deg[0]) or (el_deg > el_limit_deg[1]): continue if proximity_filter: - dist_deg = np.array([rad2deg(target.separation(prox_target, latest_timestamp, antenna)) + dist_deg = np.array([target.separation(prox_target, latest_timestamp, antenna).deg for prox_target in proximity_targets]) if (dist_deg < dist_limit_deg[0]).any() or (dist_deg > dist_limit_deg[1]).any(): continue diff --git a/katpoint/target.py b/katpoint/target.py index 27d0934..05b20ba 100644 --- a/katpoint/target.py +++ b/katpoint/target.py @@ -187,41 +187,6 @@ def format_katcp(self): """String representation if object is passed as parameter to KATCP command.""" return self.description - def _set_timestamp_antenna_defaults(self, timestamp, antenna): - """Set defaults for timestamp and antenna, if they are unspecified. - - If *timestamp* is None, it is replaced by the current time. If *antenna* - is None, it is replaced by the default antenna for the target. - - Parameters - ---------- - timestamp : :class:`Timestamp` object or equivalent, or sequence, or None - Timestamp(s) in UTC seconds since Unix epoch (None means now) - antenna : :class:`Antenna` object, or None - Antenna which points at target - - Returns - ------- - timestamp : :class:`Timestamp` object or equivalent, or sequence - Timestamp(s) in UTC seconds since Unix epoch - antenna : :class:`Antenna` object - Antenna which points at target - - Raises - ------ - ValueError - If no antenna is specified, and no default antenna was set either - """ - if antenna is None: - antenna = self.antenna - if antenna is None: - raise ValueError('Antenna object needed to calculate target position') - if is_iterable(timestamp): - time = [Timestamp(t).time for t in timestamp] - else: - time = Timestamp(timestamp).time - return time, antenna - @property def body_type(self): """Type of target body, as a string tag.""" @@ -313,38 +278,63 @@ def add_tags(self, tags): self.tags.append(tag) return self + def _normalise_antenna(self, antenna, required=False): + """Set default antenna if unspecified and check that antenna is valid. + + If `antenna` is `None`, it is replaced by the default antenna for the + target (which could also be `None`). Raise a :class:`ValueError` if + an antenna is required and none is provided. + + Parameters + ---------- + antenna : :class:`Antenna` or None + Antenna which points at target + required : bool, optional + True if it is an error to have no valid antenna + + Returns + ------- + antenna : :class:`Antenna` or None + Antenna which points at target (not None if `required` is True) + location : :class:`~astropy.coordinates.EarthLocation` or None + Location of antenna on Earth (not None if `required` is True) + + Raises + ------ + ValueError + If no antenna could be found and one is required + """ + if antenna is None: + antenna = self.antenna + if required and antenna is None: + raise ValueError('Antenna object needed to calculate target position') + location = antenna.earth_location if antenna is not None else None + return antenna, location + def azel(self, timestamp=None, antenna=None): """Calculate target (az, el) coordinates as seen from antenna at time(s). Parameters ---------- - timestamp : :class:`Timestamp` object or equivalent, or sequence, optional + timestamp : :class:`Timestamp` object or equivalent, optional Timestamp(s) in UTC seconds since Unix epoch (defaults to now) antenna : :class:`Antenna` object, optional Antenna which points at target (defaults to default antenna) Returns ------- - azel : :class:`astropy.coordinates.AzEl` object, or array of same shape as *timestamp* - AzEl(s), in radians + azel : :class:`~astropy.coordinates.AltAz` + Azimuth and elevation in `AltAz` frame Raises ------ ValueError If no antenna is specified, and no default antenna was set either """ - time, antenna = self._set_timestamp_antenna_defaults(timestamp, antenna) - location = antenna.earth_location - - def _scalar_azel(t): - """Calculate (az, el) coordinates for a single time instant.""" - altaz = AltAz(obstime=t, location=location) - return self.body.compute(altaz, obstime=t, location=location) - if is_iterable(time): - azel = np.array([_scalar_azel(t) for t in time], dtype=object) - return azel - else: - return _scalar_azel(time) + time = Timestamp(timestamp).time + _, location = self._normalise_antenna(antenna) + altaz = AltAz(obstime=time, location=location) + return self.body.compute(altaz, obstime=time, location=location) def apparent_radec(self, timestamp=None, antenna=None): """Calculate target's apparent (ra, dec) coordinates as seen from antenna at time(s). @@ -358,78 +348,53 @@ def apparent_radec(self, timestamp=None, antenna=None): Parameters ---------- - timestamp : :class:`Timestamp` object or equivalent, or sequence, optional + timestamp : :class:`Timestamp` object or equivalent, optional Timestamp(s) in UTC seconds since Unix epoch (defaults to now) antenna : :class:`Antenna` object, optional Antenna which points at target (defaults to default antenna) Returns ------- - ra : :class:`ephem.Angle` object, or array of same shape as *timestamp* - Right ascension, in radians - dec : :class:`ephem.Angle` object, or array of same shape as *timestamp* - Declination, in radians + radec : :class:`~astropy.coordinates.CIRS` + Right ascension and declination in `CIRS` frame Raises ------ ValueError If no antenna is specified, and no default antenna was set either """ - time, antenna = self._set_timestamp_antenna_defaults(timestamp, antenna) - location = antenna.earth_location - - def _scalar_radec(t): - """Calculate CIRS (ra, dec) coordinates for a single time instant.""" - return self.body.compute(CIRS(obstime=t), obstime=t, location=location) - if is_iterable(time): - radec = np.array([_scalar_radec(t) for t in time]) - return radec[:, 0], radec[:, 1] - else: - return _scalar_radec(time) + time = Timestamp(timestamp).time + _, location = self._normalise_antenna(antenna) + return self.body.compute(CIRS(obstime=time), obstime=time, location=location) def astrometric_radec(self, timestamp=None, antenna=None): """Calculate target's astrometric (ra, dec) coordinates as seen from antenna at time(s). - This calculates the J2000 *astrometric geocentric position* of the + This calculates the ICRS *astrometric barycentric position* of the target, in equatorial coordinates. This is its star atlas position for - the epoch of J2000. + the epoch of J2000, as seen from the Solar System barycentre (also + called "catalog coordinates" in SOFA). Parameters ---------- - timestamp : :class:`Timestamp` object or equivalent, or sequence, optional + timestamp : :class:`Timestamp` object or equivalent, optional Timestamp(s) in UTC seconds since Unix epoch (defaults to now) antenna : :class:`Antenna` object, optional Antenna which points at target (defaults to default antenna) Returns ------- - ra : :class:`ephem.Angle` object, or array of same shape as *timestamp* - Right ascension, in radians - dec : :class:`ephem.Angle` object, or array of same shape as *timestamp* - Declination, in radians + radec : :class:`~astropy.coordinates.ICRS` + Right ascension and declination in `ICRS` frame Raises ------ ValueError If no antenna is specified, and no default antenna was set either """ - if self.body_type == 'radec': - radec = self.body.compute(ICRS) - if is_iterable(timestamp): - return np.tile(radec, len(timestamp)) - else: - return radec - time, antenna = self._set_timestamp_antenna_defaults(timestamp, antenna) - location = antenna.earth_location - - def _scalar_radec(t): - """Calculate ICRS (ra, dec) coordinates for a single time instant.""" - return self.body.compute(ICRS, obstime=t, location=location) - if is_iterable(time): - radec = np.array([_scalar_radec(t) for t in time]) - return radec - else: - return _scalar_radec(time) + time = Timestamp(timestamp).time + _, location = self._normalise_antenna(antenna) + return self.body.compute(ICRS, obstime=time, location=location) # The default (ra, dec) coordinates are the astrometric ones radec = astrometric_radec @@ -438,42 +403,29 @@ def galactic(self, timestamp=None, antenna=None): """Calculate target's galactic (l, b) coordinates as seen from antenna at time(s). This calculates the galactic coordinates of the target, based on the - J2000 *astrometric* equatorial coordinates. This is its position relative - to the Galactic reference frame for the epoch of J2000. + ICRS *astrometric barycentric* coordinates. This is its position + relative to the `Galactic` frame for the epoch of J2000. Parameters ---------- - timestamp : :class:`Timestamp` object or equivalent, or sequence, optional + timestamp : :class:`Timestamp` object or equivalent, optional Timestamp(s) in UTC seconds since Unix epoch (defaults to now) antenna : :class:`Antenna` object, optional Antenna which points at target (defaults to default antenna) Returns ------- - l : :class:`ephem.Angle` object, or array of same shape as *timestamp* - Galactic longitude, in radians - b : :class:`ephem.Angle` object, or array of same shape as *timestamp* - Galactic latitude, in radians + lb : :class:`~astropy.coordinates.Galactic` + Galactic longitude, *l*, and latitude, *b*, in `Galactic` frame Raises ------ ValueError If no antenna is specified, and no default antenna was set either """ - if self.body_type == 'gal': - gal = self.body.compute(Galactic) - if is_iterable(timestamp): - return np.tile(gal.l, len(timestamp)), np.tile(gal.b, len(timestamp)) - else: - return gal - radec = self.astrometric_radec(timestamp, antenna) - if is_iterable(radec): - lb = np.array([SkyCoord(radec[n], frame=ICRS).transform_to(Galactic) - for n in range(len(radec))]) - return np.array([g.l for g in lb]), np.array([g.b for g in lb]) - else: - gal = SkyCoord(radec, frame=ICRS).transform_to(Galactic) - return gal + time = Timestamp(timestamp).time + _, location = self._normalise_antenna(antenna) + return self.body.compute(Galactic, obstime=time, location=location) def parallactic_angle(self, timestamp=None, antenna=None): """Calculate parallactic angle on target as seen from antenna at time(s). @@ -488,7 +440,7 @@ def parallactic_angle(self, timestamp=None, antenna=None): Parameters ---------- - timestamp : :class:`Timestamp` object or equivalent, or sequence, optional + timestamp : :class:`Timestamp` object or equivalent, optional Timestamp(s) in UTC seconds since Unix epoch (defaults to now) antenna : :class:`Antenna` object, optional Antenna which points at target (defaults to default antenna) @@ -512,8 +464,8 @@ def parallactic_angle(self, timestamp=None, antenna=None): .. _`AIPS++ Glossary`: http://www.astron.nl/aips++/docs/glossary/p.html .. _`Starlink Project`: http://www.starlink.rl.ac.uk """ - time, antenna = self._set_timestamp_antenna_defaults(timestamp, antenna) - location = antenna.earth_location + time = Timestamp(timestamp).time + antenna, location = self._normalise_antenna(antenna, required=True) # Get apparent hour angle and declination radec = self.apparent_radec(time, antenna) ha = antenna.local_sidereal_time(time) - radec.ra @@ -538,7 +490,7 @@ def geometric_delay(self, antenna2, timestamp=None, antenna=None): ---------- antenna2 : :class:`Antenna` object Second antenna of baseline pair (baseline vector points toward it) - timestamp : :class:`Timestamp` object or equivalent, or sequence, optional + timestamp : :class:`Timestamp` object or equivalent, optional Timestamp(s) in UTC seconds since Unix epoch (defaults to now) antenna : :class:`Antenna` object, optional First (reference) antenna of baseline pair, which also serves as @@ -563,39 +515,19 @@ def geometric_delay(self, antenna2, timestamp=None, antenna=None): pointing from the reference antenna to the second antenna, all in local ENU coordinates relative to the reference antenna. """ - time, antenna = self._set_timestamp_antenna_defaults(timestamp, antenna) + time = Timestamp(timestamp).time + antenna, _ = self._normalise_antenna(antenna, required=True) # Obtain baseline vector from reference antenna to second antenna baseline_m = antenna.baseline_toward(antenna2) - # Obtain direction vector(s) from reference antenna to target - azel = self.azel(time, antenna) - if is_iterable(azel): - az = np.array([i.az.rad for i in azel]) - el = np.array([i.alt.rad for i in azel]) - else: - az = azel.az.rad - el = azel.alt.rad - targetdir = azel_to_enu(az, el) - # Dot product of vectors is w coordinate, and delay is time taken by EM wave to traverse this - delay = - np.dot(baseline_m, targetdir) / lightspeed - # Numerically estimate delay rate from difference across 1-second interval spanning timestamp(s) - azel = self.azel(np.array(time) - 0.5 * u.s.to(u.day), antenna) - if is_iterable(azel): - az = np.array([i.az.rad for i in azel]) - el = np.array([i.alt.rad for i in azel]) - else: - az = azel.az.rad - el = azel.alt.rad - targetdir_before = azel_to_enu(az, el) - azel = self.azel(np.array(time) + 0.5 * u.s.to(u.day), antenna) - if is_iterable(azel): - az = np.array([i.az.rad for i in azel]) - el = np.array([i.alt.rad for i in azel]) - else: - az = azel.az.rad - el = azel.alt.rad - targetdir_after = azel_to_enu(az, el) - delay_rate = - (np.dot(baseline_m, targetdir_after) - np.dot(baseline_m, targetdir_before)) / lightspeed - return delay, delay_rate + # Obtain direction vector(s) from reference antenna to target, and numerically + # estimate delay rate from difference across 1-second interval spanning timestamp(s) + times = time[..., np.newaxis] + np.array((-0.5, 0.0, 0.5)) * u.s.to(u.day) + azel = self.azel(times, antenna) + targetdirs = np.array(azel_to_enu(azel.az.rad, azel.alt.rad)) + # Dot product of vectors is w coordinate, and + # delay is time taken by EM wave to traverse this + delays = - np.einsum('j,j...', baseline_m, targetdirs) / lightspeed + return delays[..., 1], delays[..., 2] - delays[..., 0] def uvw_basis(self, timestamp=None, antenna=None): """Calculate the coordinate transformation from local ENU coordinates @@ -610,7 +542,7 @@ def uvw_basis(self, timestamp=None, antenna=None): Parameters ---------- - timestamp : :class:`Timestamp` object or equivalent, or sequence, optional + timestamp : :class:`Timestamp` object or equivalent, optional Timestamp(s) in UTC seconds since Unix epoch (defaults to now) antenna : :class:`Antenna` object, optional Reference antenna of baseline pairs, which also serves as @@ -625,8 +557,9 @@ def uvw_basis(self, timestamp=None, antenna=None): the first two dimensions correspond to the matrix and the final dimension to the timestamp. """ - time, antenna = self._set_timestamp_antenna_defaults(timestamp, antenna) - if is_iterable(time) and self.body_type != 'radec': + time = Timestamp(timestamp).time + antenna, _ = self._normalise_antenna(antenna, required=True) + if not time.isscalar and self.body_type != 'radec': # Some calculations depend on ra/dec in a way that won't easily # vectorise. bases = [self.uvw_basis(t, antenna) for t in time] @@ -643,7 +576,7 @@ def uvw_basis(self, timestamp=None, antenna=None): # single precision and this method suffers from loss of precision. # 0.03 was found by experimentation (albeit on a single data set) to # to be large enough to avoid the numeric instability. - if is_iterable(time): + if not time.isscalar: # Due to the test above, this is a radec target and so timestamp # doesn't matter. But we want a scalar. radec = self.radec(None, antenna) @@ -653,24 +586,12 @@ def uvw_basis(self, timestamp=None, antenna=None): offset = construct_radec_target(radec.ra.rad, radec.dec.rad + 0.03 * offset_sign) # Get offset az-el vector at current epoch pointed to by reference antenna offset_azel = offset.azel(time, antenna) + # enu vector pointing from reference antenna to offset point + z = np.array(azel_to_enu(offset_azel.az.rad, offset_azel.alt.rad)) # Obtain direction vector(s) from reference antenna to target azel = self.azel(time, antenna) - if type(azel) == np.ndarray: - az = np.array([a.az.rad for a in azel]) - el = np.array([a.alt.rad for a in azel]) - else: - az = azel.az.rad - el = azel.alt.rad # w axis points toward target - w = np.array(azel_to_enu(az, el)) - # enu vector pointing from reference antenna to offset point - if type(offset_azel) == np.ndarray: - offset_az = np.array([a.az.rad for a in offset_azel]) - offset_el = np.array([a.alt.rad for a in offset_azel]) - else: - offset_az = offset_azel.az.rad - offset_el = offset_azel.alt.rad - z = np.array(azel_to_enu(offset_az, offset_el)) + w = np.array(azel_to_enu(azel.az.rad, azel.alt.rad)) # u axis is orthogonal to z and w, and row_stack makes it 2-D array of column vectors u = np.row_stack(np.cross(z, w, axis=0)) * offset_sign u_norm = np.sqrt(np.sum(u ** 2, axis=0)) @@ -693,7 +614,7 @@ def uvw(self, antenna2, timestamp=None, antenna=None): ---------- antenna2 : :class:`Antenna` object or sequence Second antenna of baseline pair (baseline vector points toward it) - timestamp : :class:`Timestamp` object or equivalent, or sequence, optional + timestamp : :class:`Timestamp` object or equivalent, optional Timestamp(s) in UTC seconds since Unix epoch (defaults to now) antenna : :class:`Antenna` object, optional First (reference) antenna of baseline pair, which also serves as @@ -713,7 +634,8 @@ def uvw(self, antenna2, timestamp=None, antenna=None): This avoids having to convert (az, el) angles to (ha, dec) angles and uses linear algebra throughout instead. """ - time, antenna = self._set_timestamp_antenna_defaults(timestamp, antenna) + time = Timestamp(timestamp).time + antenna, _ = self._normalise_antenna(antenna, required=True) # Obtain basis vectors basis = self.uvw_basis(time, antenna) # Obtain baseline vector from reference antenna to second antenna @@ -741,7 +663,7 @@ def lmn(self, ra, dec, timestamp=None, antenna=None): Right ascension of the other target, in radians dec : float or array Declination of the other target, in radians - timestamp : :class:`Timestamp` object or equivalent, or sequence, optional + timestamp : :class:`Timestamp` object or equivalent, optional Timestamp(s) in UTC seconds since Unix epoch (defaults to now) antenna : :class:`Antenna` object, optional Pointing reference (defaults to default antenna) @@ -831,13 +753,13 @@ def flux_density_stokes(self, flux_freq_MHz=None): return self.flux_model.flux_density_stokes(flux_freq_MHz) def separation(self, other_target, timestamp=None, antenna=None): - """Angular separation between this target and another one. + """Angular separation between this target and another as viewed from antenna. Parameters ---------- other_target : :class:`Target` object The other target - timestamp : :class:`Timestamp` object or equivalent, or sequence, optional + timestamp : :class:`Timestamp` object or equivalent, optional Timestamp(s) when separation is measured, in UTC seconds since Unix epoch (defaults to now) antenna : class:`Antenna` object, optional @@ -846,8 +768,8 @@ def separation(self, other_target, timestamp=None, antenna=None): Returns ------- - separation : :class:`ephem.Angle` object, or array of shape of *timestamp* - Angular separation between the targets, in radians + separation : :class:`~astropy.coordinates.Angle` + Angular separation between the targets, as viewed from antenna Notes ----- @@ -855,17 +777,9 @@ def separation(self, other_target, timestamp=None, antenna=None): time and finds the angular distance between the two sets of coordinates. """ # Get a common timestamp and antenna for both targets - time, antenna = self._set_timestamp_antenna_defaults(timestamp, antenna) - - def _scalar_separation(t): - """Calculate angular separation for a single time instant.""" - azel1 = self.azel(t, antenna) - azel2 = other_target.azel(t, antenna) - return azel1.separation(azel2).rad - if is_iterable(time): - return np.array([_scalar_separation(t) for t in time]) - else: - return _scalar_separation(time) + time = Timestamp(timestamp).time + antenna, _ = self._normalise_antenna(antenna) + return self.azel(time, antenna).separation(other_target.azel(time, antenna)) def sphere_to_plane(self, az, el, timestamp=None, antenna=None, projection_type='ARC', coord_system='azel'): """Project spherical coordinates to plane with target position as reference. @@ -882,7 +796,7 @@ def sphere_to_plane(self, az, el, timestamp=None, antenna=None, projection_type= Azimuth or right ascension, in radians el : float or array Elevation or declination, in radians - timestamp : :class:`Timestamp` object or equivalent, or sequence, optional + timestamp : :class:`Timestamp` object or equivalent, optional Timestamp(s) in UTC seconds since Unix epoch (defaults to now) antenna : :class:`Antenna` object, optional Antenna pointing at target (defaults to default antenna) @@ -922,7 +836,7 @@ def plane_to_sphere(self, x, y, timestamp=None, antenna=None, projection_type='A Azimuth-like coordinate(s) on plane, in radians y : float or array Elevation-like coordinate(s) on plane, in radians - timestamp : :class:`Timestamp` object or equivalent, or sequence, optional + timestamp : :class:`Timestamp` object or equivalent, optional Timestamp(s) in UTC seconds since Unix epoch (defaults to now) antenna : :class:`Antenna` object, optional Antenna pointing at target (defaults to default antenna) diff --git a/katpoint/test/test_target.py b/katpoint/test/test_target.py index 22e7642..e461160 100644 --- a/katpoint/test/test_target.py +++ b/katpoint/test/test_target.py @@ -286,12 +286,12 @@ def test_separation(self): azel_sun = sun.azel(self.ts, self.ant1) azel = katpoint.construct_azel_target(azel_sun.az, azel_sun.alt) sep = sun.separation(azel, self.ts, self.ant1) - np.testing.assert_almost_equal(sep, 0.0) + np.testing.assert_almost_equal(sep.rad, 0.0) sep = azel.separation(sun, self.ts, self.ant1) - np.testing.assert_almost_equal(sep, 0.0) + np.testing.assert_almost_equal(sep.rad, 0.0) azel2 = katpoint.construct_azel_target(azel_sun.az, azel_sun.alt + Angle(0.01, unit=u.rad)) sep = azel.separation(azel2, self.ts, self.ant1) - np.testing.assert_almost_equal(sep, 0.01, decimal=7) + np.testing.assert_almost_equal(sep.rad, 0.01, decimal=7) def test_projection(self): """Test projection.""" From bfd75801c633e2595e718029c752382b9ed99e92 Mon Sep 17 00:00:00 2001 From: Ludwig Schwardt Date: Tue, 28 Jul 2020 16:58:29 +0200 Subject: [PATCH 054/122] Fix visibility_list display and speed it up Change the angle display back to sexagesimal. Since Astropy's (az, el) call tends to be costly (10 ms for a celestial source and 100 ms for a Solar System object), try to vectorise as much as possible. The test catalogue has 115 celestial sources and 10 Solar System objects, so each catalogue-wide (az, el) costs 2 seconds... Similar to geometric_delay, combine the calculation to see if a source is rising or setting with the actual position calculation, and get rid of the sort() call, which does a redundant azel(). The number of `azel` calls drops from 5 to 2 if the fringe rate is also requested, and 1 otherwise, with a corresponding speed-up. The ultimate would be to pool all the celestial targets into a single SkyCoord... The test_catalogue tests now takes 26 seconds on my laptop where it used to take 45 seconds. There's no need to `import time` in the tests. --- katpoint/catalogue.py | 15 ++++++++++----- katpoint/test/test_catalogue.py | 6 ++---- 2 files changed, 12 insertions(+), 9 deletions(-) diff --git a/katpoint/catalogue.py b/katpoint/catalogue.py index 3943cb4..f3db19b 100644 --- a/katpoint/catalogue.py +++ b/katpoint/catalogue.py @@ -939,10 +939,13 @@ def visibility_list(self, timestamp=None, antenna=None, flux_freq_MHz=None, ante print() print('Target Azimuth Elevation < Flux Fringe period') print('------ ------- --------- - ---- -------------') - for target in self.sort('el', timestamp=timestamp, antenna=antenna, ascending=False): - azel = target.azel(timestamp, antenna) - delta_el = target.azel(timestamp + 30.0, antenna).alt.deg - target.azel(timestamp - 30.0, antenna).alt.deg - el_code = '-' if (np.abs(delta_el) < 1.0 / 60.0) else ('/' if delta_el > 0.0 else '\\') + azels = [target.azel(timestamp + (-30.0, 0.0, 30.0), antenna) for target in self.targets] + elevations = [azel[1].alt.deg for azel in azels] + for index in np.argsort(elevations)[::-1]: + target = self.targets[index] + azel = azels[index][1] + delta_el = azels[index][2].alt.deg - azels[index][0].alt.deg + el_code = '-' if (np.abs(delta_el) < 1 / 60) else ('/' if delta_el > 0 else '\\') # If no flux frequency is given, do not attempt to evaluate the flux, as it will fail flux = target.flux_density(flux_freq_MHz) if flux_freq_MHz is not None else np.nan if antenna2 is not None and flux_freq_MHz is not None: @@ -954,7 +957,9 @@ def visibility_list(self, timestamp=None, antenna=None, flux_freq_MHz=None, ante # Draw horizon line print('--------------------------------------------------------------------------') above_horizon = False - line = '%-24s %12s %12s %c' % (target.name, azel.az.rad, azel.alt.rad, el_code) + az = azel.az.wrap_at('180deg').to_string(sep=':', precision=1) + el = azel.alt.to_string(sep=':', precision=1) + line = '%-24s %12s %12s %c' % (target.name, az, el, el_code) line = line + ' %7.1f' % (flux,) if not np.isnan(flux) else line + ' ' if fringe_period is not None: line += ' %10.2f' % (fringe_period,) diff --git a/katpoint/test/test_catalogue.py b/katpoint/test/test_catalogue.py index 94621d0..0fb64a9 100644 --- a/katpoint/test/test_catalogue.py +++ b/katpoint/test/test_catalogue.py @@ -16,8 +16,6 @@ """Tests for the catalogue module.""" -import time - import pytest from numpy.testing import assert_allclose @@ -25,10 +23,10 @@ # Use the current year in TLE epochs to avoid pyephem crash due to expired TLEs -YY = time.localtime().tm_year % 100 +YY = katpoint.Timestamp().time.ymdhms[0] % 100 FLUX_TARGET = katpoint.Target('flux, radec, 0.0, 0.0, (1.0 2.0 2.0 0.0 0.0)') ANTENNA = katpoint.Antenna('XDM, -25:53:23.05075, 27:41:03.36453, 1406.1086, 15.0') -TIMESTAMP = time.mktime(time.strptime('2009/06/14 12:34:56', '%Y/%m/%d %H:%M:%S')) +TIMESTAMP = '2009/06/14 12:34:56' def test_catalogue_basic(): From e47edd2143b172e801b9b0151817280ead73b0c7 Mon Sep 17 00:00:00 2001 From: Ludwig Schwardt Date: Wed, 29 Jul 2020 23:41:17 +0200 Subject: [PATCH 055/122] Let parallactic angle be an Astropy Angle This is more convenient than a float in radians or even a Quantity. Clean up docstring. --- katpoint/target.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/katpoint/target.py b/katpoint/target.py index 05b20ba..ae3d32d 100644 --- a/katpoint/target.py +++ b/katpoint/target.py @@ -20,7 +20,7 @@ import astropy.units as u from astropy.coordinates import SkyCoord # High-level coordinates from astropy.coordinates import ICRS, Galactic, FK4, AltAz, CIRS # Low-level frames -from astropy.coordinates import Latitude, Longitude # Angles +from astropy.coordinates import Latitude, Longitude, Angle # Angles from astropy.time import Time from .timestamp import Timestamp @@ -447,8 +447,8 @@ def parallactic_angle(self, timestamp=None, antenna=None): Returns ------- - parangle : float, or array of same shape as *timestamp* - Parallactic angle, in radians + parangle : :class:`~astropy.coordinates.Angle`, same shape as *timestamp* + Parallactic angle Raises ------ @@ -469,9 +469,9 @@ def parallactic_angle(self, timestamp=None, antenna=None): # Get apparent hour angle and declination radec = self.apparent_radec(time, antenna) ha = antenna.local_sidereal_time(time) - radec.ra - return np.arctan2(np.sin(ha), - np.tan(location.lat.rad) * np.cos(radec.dec) - - np.sin(radec.dec) * np.cos(ha)) + y = np.sin(ha) + x = np.tan(location.lat.rad) * np.cos(radec.dec) - np.sin(radec.dec) * np.cos(ha) + return Angle(np.arctan2(y, x)) def geometric_delay(self, antenna2, timestamp=None, antenna=None): """Calculate geometric delay between two antennas pointing at target. From e505c8fc31713d3acfeae6636e1d562e32927083 Mon Sep 17 00:00:00 2001 From: Ludwig Schwardt Date: Wed, 29 Jul 2020 23:43:17 +0200 Subject: [PATCH 056/122] Restore target tests that used to fail Fulfill the dream of having calculated coordinates that *exactly* match the corresponding ones with which a Target was constructed, for all relevant body types. So if you have an radec Target, for example, Target.radec() should return the original coordinates with no need for a timestamp or antenna. This had issues in the original katpoint due to limitations in libastro, but was eventually sorted out without updating the test. Only Galactic still has an issue round-tripping on PyEphem. --- katpoint/test/test_target.py | 34 ++++++++++++++++++---------------- 1 file changed, 18 insertions(+), 16 deletions(-) diff --git a/katpoint/test/test_target.py b/katpoint/test/test_target.py index e461160..8c3f935 100644 --- a/katpoint/test/test_target.py +++ b/katpoint/test/test_target.py @@ -75,32 +75,34 @@ def test_construct_target(self): def test_constructed_coords(self): """Test whether calculated coordinates match those with which it is constructed.""" - # azel = katpoint.Target(self.azel_target) - # calc_azel = azel.azel() - # calc_az = calc_azel.az - # calc_el = calc_azel.alt - # assert calc_az.deg == 10.0, 'Calculated az does not match specified value in azel target' - # assert calc_el.deg == -10.0, 'Calculated el does not match specified value in azel target' + # azel + azel = katpoint.Target(self.azel_target) + calc_azel = azel.azel() + calc_az = calc_azel.az + calc_el = calc_azel.alt + assert calc_az.deg == 10.0 + assert calc_el.deg == -10.0 + # radec (degrees) radec = katpoint.Target(self.radec_target) calc_radec = radec.radec() calc_ra = calc_radec.ra calc_dec = calc_radec.dec - # You would think that these could be made exactly equal, but the following assignment is inexact: - # body = ephem.FixedBody() - # body._ra = ra - # Then body._ra != ra... Possibly due to double vs float? This problem goes all the way to libastro. - np.testing.assert_almost_equal(calc_ra.deg, 20.0, decimal=4) - np.testing.assert_almost_equal(calc_dec.deg, -20.0, decimal=4) + assert calc_ra.deg == 20.0 + assert calc_dec.deg == -20.0 + # radec (hours) radec_rahours = katpoint.Target(self.radec_target_rahours) calc_radec_rahours = radec_rahours.radec() - calc_rahours = calc_radec_rahours.ra - np.testing.assert_almost_equal(calc_rahours.deg, 20.0 * 360.0 / 24.0, decimal=4) + calc_ra = calc_radec_rahours.ra + calc_dec = calc_radec_rahours.dec + assert calc_ra.hms == (20, 0, 0) + assert calc_dec.deg == -20.0 + # gal lb = katpoint.Target(self.gal_target) calc_lb = lb.galactic() calc_l = calc_lb.l calc_b = calc_lb.b - np.testing.assert_almost_equal(calc_l.deg, 30.0, decimal=4) - np.testing.assert_almost_equal(calc_b.deg, -30.0, decimal=4) + assert calc_l.deg == 30.0 + assert calc_b.deg == -30.0 def test_add_tags(self): """Test adding tags.""" From cda3c55a64feb16bc20a492a759657f3b57d9288 Mon Sep 17 00:00:00 2001 From: Ludwig Schwardt Date: Thu, 30 Jul 2020 00:02:01 +0200 Subject: [PATCH 057/122] Check return values in basic coordinate test This test started as a simple way to get good code coverage on the main coordinate methods. But we might as well check the coordinate values for posterity. Compare string versions of the angles, which have slightly lower precision but are more human-readable. Compare with PyEphem as well. --- katpoint/test/test_target.py | 27 +++++++++++++++++++++------ 1 file changed, 21 insertions(+), 6 deletions(-) diff --git a/katpoint/test/test_target.py b/katpoint/test/test_target.py index 8c3f935..047a023 100644 --- a/katpoint/test/test_target.py +++ b/katpoint/test/test_target.py @@ -198,12 +198,27 @@ def setup(self): self.uvw = [10.820796672358002, -9.1055125816993954, -2.22044604925e-16] def test_coords(self): - """Test coordinate conversions for coverage.""" - self.target.azel(self.ts, self.ant1) - self.target.apparent_radec(self.ts, self.ant1) - self.target.astrometric_radec(self.ts, self.ant1) - self.target.galactic(self.ts, self.ant1) - self.target.parallactic_angle(self.ts, self.ant1) + """Test coordinate conversions for coverage and verification.""" + coord = self.target.azel(self.ts, self.ant1) + assert coord.az.deg == 45 # PyEphem: 45 + assert coord.alt.deg == 75 # PyEphem: 75 + coord = self.target.apparent_radec(self.ts, self.ant1) + ra_hour = coord.ra.to_string(unit='hour', sep=':', precision=8) + dec_deg = coord.dec.to_string(sep=':', precision=8) + assert ra_hour == '8:53:03.49166920' # PyEphem: 8:53:09.60 (same as astrometric) + assert dec_deg == '-19:54:51.92328722' # PyEphem: -19:51:43.0 (same as astrometric) + coord = self.target.astrometric_radec(self.ts, self.ant1) + ra_hour = coord.ra.to_string(unit='hour', sep=':', precision=8) + dec_deg = coord.dec.to_string(sep=':', precision=8) + assert ra_hour == '8:53:09.60397465' # PyEphem: 8:53:09.60 + assert dec_deg == '-19:51:42.87773802' # PyEphem: -19:51:43.0 + coord = self.target.galactic(self.ts, self.ant1) + l_deg = coord.l.to_string(sep=':', precision=8) + b_deg = coord.b.to_string(sep=':', precision=8) + assert l_deg == '245:34:49.20442837' # PyEphem: 245:34:49.3 + assert b_deg == '15:36:24.87974969' # PyEphem: 15:36:24.7 + coord = self.target.parallactic_angle(self.ts, self.ant1) + assert coord.deg == -140.27959356633625 # PyEphem: -140.34440985011398 def test_delay(self): """Test geometric delay.""" From 309fd1e7140500552967a5c97ce847d690a3aa08 Mon Sep 17 00:00:00 2001 From: Ludwig Schwardt Date: Thu, 30 Jul 2020 00:07:04 +0200 Subject: [PATCH 058/122] Fix docstrings and improve line lengths --- katpoint/target.py | 8 ++++---- katpoint/test/test_target.py | 9 ++++----- 2 files changed, 8 insertions(+), 9 deletions(-) diff --git a/katpoint/target.py b/katpoint/target.py index ae3d32d..be96f34 100644 --- a/katpoint/target.py +++ b/katpoint/target.py @@ -323,7 +323,7 @@ def azel(self, timestamp=None, antenna=None): Returns ------- - azel : :class:`~astropy.coordinates.AltAz` + azel : :class:`~astropy.coordinates.AltAz`, same shape as *timestamp* Azimuth and elevation in `AltAz` frame Raises @@ -355,7 +355,7 @@ def apparent_radec(self, timestamp=None, antenna=None): Returns ------- - radec : :class:`~astropy.coordinates.CIRS` + radec : :class:`~astropy.coordinates.CIRS`, same shape as *timestamp* Right ascension and declination in `CIRS` frame Raises @@ -384,7 +384,7 @@ def astrometric_radec(self, timestamp=None, antenna=None): Returns ------- - radec : :class:`~astropy.coordinates.ICRS` + radec : :class:`~astropy.coordinates.ICRS`, same shape as *timestamp* Right ascension and declination in `ICRS` frame Raises @@ -415,7 +415,7 @@ def galactic(self, timestamp=None, antenna=None): Returns ------- - lb : :class:`~astropy.coordinates.Galactic` + lb : :class:`~astropy.coordinates.Galactic`, same shape as *timestamp* Galactic longitude, *l*, and latitude, *b*, in `Galactic` frame Raises diff --git a/katpoint/test/test_target.py b/katpoint/test/test_target.py index 047a023..8735719 100644 --- a/katpoint/test/test_target.py +++ b/katpoint/test/test_target.py @@ -55,15 +55,13 @@ def test_construct_target(self): assert radec3 == radec4, 'Special radec constructor (sexagesimal) failed' radec5 = katpoint.construct_radec_target('20:00:00.0', '-00:30:00.0') radec6 = katpoint.construct_radec_target('300.0', '-0.5') - assert radec5 == radec6, ( - 'Special radec constructor (decimal <-> sexagesimal) failed') + assert radec5 == radec6, 'Special radec constructor (decimal <-> sexagesimal) failed' # Check that description string updates when object is updated t1 = katpoint.Target('piet, azel, 20, 30') t2 = katpoint.Target('piet | bollie, azel, 20, 30') assert t1 != t2, 'Targets should not be equal' t1.aliases += ['bollie'] - assert t1.description == t2.description, ( - 'Target description string not updated') + assert t1.description == t2.description, 'Target description string not updated' assert t1 == t2.description, 'Equality with description string failed' assert t1 == t2, 'Equality with target failed' assert t1 == katpoint.Target(t2), 'Construction with target object failed' @@ -306,7 +304,8 @@ def test_separation(self): np.testing.assert_almost_equal(sep.rad, 0.0) sep = azel.separation(sun, self.ts, self.ant1) np.testing.assert_almost_equal(sep.rad, 0.0) - azel2 = katpoint.construct_azel_target(azel_sun.az, azel_sun.alt + Angle(0.01, unit=u.rad)) + azel2 = katpoint.construct_azel_target(azel_sun.az, + azel_sun.alt + Angle(0.01, unit=u.rad)) sep = azel.separation(azel2, self.ts, self.ant1) np.testing.assert_almost_equal(sep.rad, 0.01, decimal=7) From 710b2b2c02af0e57a81b45d053746ad3aa98d663 Mon Sep 17 00:00:00 2001 From: Ludwig Schwardt Date: Thu, 30 Jul 2020 14:28:00 +0200 Subject: [PATCH 059/122] Improve error checking of missing antennas Depending on its body type, some coordinate methods of Target can be called without an Antenna and others cannot. For example, an "radec" Target can return radec and galactic without an Antenna but needs one for azel. This has to be checked in the underlying Body, which hopefully knows which frames will require an EarthLocation in compute() and which don't. Raise a ValueError if the location is missing and it is needed. If you don't, you typically get a cryptic AttributeError from the bowels of Astropy. Add tests to check that the expected methods raise errors (or not) on various body types. Some caveats: - The apparent radec is currently geocentric but could revert back to topocentric at some stage. - While a location is mostly needed by AltAz frames, this could extend to other bodies in future. The latest Astropy uses the location in get_body_barycentric() for light travel times, so it might make sense to depend on Antennas in SolarSystemBody too. --- katpoint/body.py | 12 +++++++++++ katpoint/target.py | 10 ++++----- katpoint/test/test_target.py | 40 ++++++++++++++++++++++++++++++++---- 3 files changed, 53 insertions(+), 9 deletions(-) diff --git a/katpoint/body.py b/katpoint/body.py index 5f9a9ae..0aacaf7 100644 --- a/katpoint/body.py +++ b/katpoint/body.py @@ -75,6 +75,9 @@ def compute(self, frame, obstime=None, location=None): :class:`~astropy.coordinates.SkyCoord` The computed coordinates as a new object """ + if isinstance(frame, AltAz) and frame.location is None: + raise ValueError('Body needs a location to calculate (az, el) coordinates - ' + 'did you specify an Antenna?') return self.coord.transform_to(frame) @@ -109,6 +112,9 @@ def __init__(self, name): def compute(self, frame, obstime, location=None): """Determine position of body in GCRS at given time and transform to `frame`.""" + if isinstance(frame, AltAz) and frame.location is None: + raise ValueError('Body needs a location to calculate (az, el) coordinates - ' + 'did you specify an Antenna?') gcrs = get_body(self.name, obstime, location) return gcrs.transform_to(frame) @@ -127,6 +133,9 @@ def __init__(self, name): def compute(self, frame, obstime, location): """Determine position of body at the given time and transform to `frame`.""" + if location is None: + raise ValueError('EarthSatelliteBody needs a location to calculate coordinates - ' + 'did you specify an Antenna?') # Create an SGP4 satellite object self._sat = sgp4.model.Satellite() self._sat.whichconst = sgp4.earth_gravity.wgs84 @@ -384,6 +393,9 @@ def compute(self, frame, obstime, location): if isinstance(frame, AltAz) and altaz.is_equivalent_frame(frame): return altaz else: + if location is None: + raise ValueError('StationaryBody needs a location to calculate coordinates - ' + 'did you specify an Antenna?') return altaz.transform_to(frame) diff --git a/katpoint/target.py b/katpoint/target.py index be96f34..4e3b631 100644 --- a/katpoint/target.py +++ b/katpoint/target.py @@ -302,7 +302,7 @@ def _normalise_antenna(self, antenna, required=False): Raises ------ ValueError - If no antenna could be found and one is required + If an antenna is required and none could be found """ if antenna is None: antenna = self.antenna @@ -329,7 +329,7 @@ def azel(self, timestamp=None, antenna=None): Raises ------ ValueError - If no antenna is specified, and no default antenna was set either + If no antenna is specified and body type requires it for (az, el) """ time = Timestamp(timestamp).time _, location = self._normalise_antenna(antenna) @@ -361,7 +361,7 @@ def apparent_radec(self, timestamp=None, antenna=None): Raises ------ ValueError - If no antenna is specified, and no default antenna was set either + If no antenna is specified and body type requires it for (ra, dec) """ time = Timestamp(timestamp).time _, location = self._normalise_antenna(antenna) @@ -390,7 +390,7 @@ def astrometric_radec(self, timestamp=None, antenna=None): Raises ------ ValueError - If no antenna is specified, and no default antenna was set either + If no antenna is specified and body type requires it for (ra, dec) """ time = Timestamp(timestamp).time _, location = self._normalise_antenna(antenna) @@ -421,7 +421,7 @@ def galactic(self, timestamp=None, antenna=None): Raises ------ ValueError - If no antenna is specified, and no default antenna was set either + If no antenna is specified and body type requires it for (l, b) """ time = Timestamp(timestamp).time _, location = self._normalise_antenna(antenna) diff --git a/katpoint/test/test_target.py b/katpoint/test/test_target.py index 8735719..bf56a7f 100644 --- a/katpoint/test/test_target.py +++ b/katpoint/test/test_target.py @@ -18,6 +18,7 @@ import time import pickle +from contextlib import contextmanager import numpy as np import pytest @@ -28,6 +29,10 @@ # Use the current year in TLE epochs to avoid potential crashes due to expired TLEs YY = time.localtime().tm_year % 100 +TLE_TARGET = ('tle, GPS BIIA-21 (PRN 09) \n' + '1 22700U 93042A {:02d}266.32333151 .00000012 00000-0 10000-3 0 805{:1d}\n' + '2 22700 55.4408 61.3790 0191986 78.1802 283.9935 2.00561720104282\n' + .format(YY, (YY // 10 + YY - 7 + 4) % 10)) class TestTargetConstruction: @@ -125,10 +130,7 @@ def test_add_tags(self): 'Sag A, gal, 0.0, 0.0', 'Zizou, radec cal, 1.4, 30.0, (1000.0 2000.0 1.0 10.0)', 'Fluffy | *Dinky, radec, 12.5, -50.0, (1.0 2.0 1.0 2.0 3.0 4.0)', - ('tle, GPS BIIA-21 (PRN 09) \n' - '1 22700U 93042A {:02d}266.32333151 .00000012 00000-0 10000-3 0 805{:1d}\n' - '2 22700 55.4408 61.3790 0191986 78.1802 283.9935 2.00561720104282\n' - .format(YY, (YY // 10 + YY - 7 + 4) % 10)), + TLE_TARGET, (', tle, GPS BIIA-22 (PRN 05) \n' '1 22779U 93054A {:02d}266.92814765 .00000062 00000-0 10000-3 0 289{:1d}\n' '2 22779 53.8943 118.4708 0081407 68.2645 292.7207 2.00558015103055\n' @@ -184,6 +186,36 @@ def test_construct_invalid_target(description): katpoint.Target(description) +NON_AZEL = 'astrometric_radec apparent_radec galactic' + + +@contextmanager +def does_not_raise(error): + yield + + +@pytest.mark.parametrize( + "description,methods,raises,error", + [ + ('azel, 10, -10', 'azel', does_not_raise, None), + ('azel, 10, -10', NON_AZEL, pytest.raises, ValueError), + ('radec, 20, -20', 'azel', pytest.raises, ValueError), + ('radec, 20, -20', NON_AZEL, does_not_raise, None), + ('gal, 30, -30', 'azel', pytest.raises, ValueError), + ('gal, 30, -30', NON_AZEL, does_not_raise, None), + ('Sun, special', 'azel', pytest.raises, ValueError), + ('Sun, special', NON_AZEL, does_not_raise, None), + (TLE_TARGET, 'azel ' + NON_AZEL, pytest.raises, ValueError), + ] +) +def test_coord_methods_without_antenna(description, methods, raises, error): + """"Test whether coordinate methods can operate without an Antenna.""" + target = katpoint.Target(description) + for method in methods.split(): + with raises(error): + getattr(target, method)() + + class TestTargetCalculations: """Test various calculations involving antennas and timestamps.""" From 5ce0e5fac6990d0016b58580fd3edda63963b9f5 Mon Sep 17 00:00:00 2001 From: Ludwig Schwardt Date: Thu, 30 Jul 2020 17:13:25 +0200 Subject: [PATCH 060/122] Ensure that coordinates match timestamp shape To maintain compatibility with the original katpoint, it is desirable to have the SkyCoords output by azel(), radec(), etc have the same shape as the timestamps. This is generally the case when the output frame contains an obstime, as with AltAz, GCRS and CIRS. An important exception is ICRS / Galactic, which has no concept of obstime. In this case, explicitly repeat the scalar coordinate to match the obstime shape in compute(). Test that this works for some basic coordinate methods. This assumes that the Target coordinate is a scalar (ie we are tracking a single body) and the Antenna location is a scalar (ie we are not doing an array at once). These assumptions might have to be revisited at some stage, but let's start small. --- katpoint/body.py | 9 ++++++++- katpoint/test/test_target.py | 19 +++++++++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/katpoint/body.py b/katpoint/body.py index 0aacaf7..325d047 100644 --- a/katpoint/body.py +++ b/katpoint/body.py @@ -78,7 +78,14 @@ def compute(self, frame, obstime=None, location=None): if isinstance(frame, AltAz) and frame.location is None: raise ValueError('Body needs a location to calculate (az, el) coordinates - ' 'did you specify an Antenna?') - return self.coord.transform_to(frame) + # If obstime is array-valued and not contained in the output frame, the transform + # will return a scalar SkyCoord. Repeat the value to match obstime shape instead. + if (obstime is not None and not obstime.isscalar + and 'obstime' not in frame.get_frame_attr_names()): + coord = self.coord.take(np.zeros_like(obstime, dtype=int)) + else: + coord = self.coord + return coord.transform_to(frame) class FixedBody(Body): diff --git a/katpoint/test/test_target.py b/katpoint/test/test_target.py index bf56a7f..a6fa22c 100644 --- a/katpoint/test/test_target.py +++ b/katpoint/test/test_target.py @@ -216,6 +216,25 @@ def test_coord_methods_without_antenna(description, methods, raises, error): getattr(target, method)() +# XXX TLE_TARGET does not support array timestamps yet +@pytest.mark.parametrize("description", ['azel, 10, -10', 'radec, 20, -20', + 'gal, 30, -30', 'Sun, special']) +def test_array_valued_azel(description): + """Test array-valued (az, el) coordinates.""" + ts = katpoint.Timestamp('2020-07-30 14:02:00') + ant1 = katpoint.Antenna('A1, -31.0, 18.0, 0.0, 12.0, 0.0 0.0 0.0') + offsets = np.array([np.arange(3), np.arange(3)]) + times = ts + offsets + assert times.time.shape == offsets.shape + target = katpoint.Target(description) + assert target.azel(times, ant1).shape == offsets.shape + assert target.astrometric_radec(times, ant1).shape == offsets.shape + assert target.apparent_radec(times, ant1).shape == offsets.shape + assert target.galactic(times, ant1).shape == offsets.shape + assert target.parallactic_angle(times, ant1).shape == offsets.shape + assert target.separation(target, times, ant1).shape == offsets.shape + + class TestTargetCalculations: """Test various calculations involving antennas and timestamps.""" From 6337df272e3ee32fdc6524e970114f3835765009 Mon Sep 17 00:00:00 2001 From: Ludwig Schwardt Date: Thu, 30 Jul 2020 17:47:38 +0200 Subject: [PATCH 061/122] Relax floating-point comparison --- katpoint/test/test_target.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/katpoint/test/test_target.py b/katpoint/test/test_target.py index a6fa22c..550fd8c 100644 --- a/katpoint/test/test_target.py +++ b/katpoint/test/test_target.py @@ -267,7 +267,7 @@ def test_coords(self): assert l_deg == '245:34:49.20442837' # PyEphem: 245:34:49.3 assert b_deg == '15:36:24.87974969' # PyEphem: 15:36:24.7 coord = self.target.parallactic_angle(self.ts, self.ant1) - assert coord.deg == -140.27959356633625 # PyEphem: -140.34440985011398 + assert coord.deg == pytest.approx(-140.279593566336) # PyEphem: -140.34440985011398 def test_delay(self): """Test geometric delay.""" From 7b64c89ca17ccd40544199fc6e48094f4597943a Mon Sep 17 00:00:00 2001 From: Ludwig Schwardt Date: Wed, 5 Aug 2020 13:06:57 +0000 Subject: [PATCH 062/122] Apply 4 suggestion(s) to 2 file(s) --- katpoint/body.py | 2 +- katpoint/target.py | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/katpoint/body.py b/katpoint/body.py index 325d047..d81cb64 100644 --- a/katpoint/body.py +++ b/katpoint/body.py @@ -107,7 +107,7 @@ class SolarSystemBody(Body): Parameters ---------- name : str or other - The name of the body (see :func:``~astropy.coordinates.get_body` + The name of the body (see :func:`~astropy.coordinates.get_body` for more details). """ diff --git a/katpoint/target.py b/katpoint/target.py index 4e3b631..5f359c5 100644 --- a/katpoint/target.py +++ b/katpoint/target.py @@ -356,7 +356,7 @@ def apparent_radec(self, timestamp=None, antenna=None): Returns ------- radec : :class:`~astropy.coordinates.CIRS`, same shape as *timestamp* - Right ascension and declination in `CIRS` frame + Right ascension and declination in CIRS frame Raises ------ @@ -385,7 +385,7 @@ def astrometric_radec(self, timestamp=None, antenna=None): Returns ------- radec : :class:`~astropy.coordinates.ICRS`, same shape as *timestamp* - Right ascension and declination in `ICRS` frame + Right ascension and declination in ICRS frame Raises ------ @@ -526,7 +526,7 @@ def geometric_delay(self, antenna2, timestamp=None, antenna=None): targetdirs = np.array(azel_to_enu(azel.az.rad, azel.alt.rad)) # Dot product of vectors is w coordinate, and # delay is time taken by EM wave to traverse this - delays = - np.einsum('j,j...', baseline_m, targetdirs) / lightspeed + delays = -np.einsum('j,j...', baseline_m, targetdirs) / lightspeed return delays[..., 1], delays[..., 2] - delays[..., 0] def uvw_basis(self, timestamp=None, antenna=None): From 5adcd0b44f56418e97769ea07c17eca76727ac21 Mon Sep 17 00:00:00 2001 From: Ludwig Schwardt Date: Wed, 5 Aug 2020 14:17:54 +0200 Subject: [PATCH 063/122] Improve timestamp description in docstrings When documenting timestamps in the `Target` and `Antenna` classes (and those that depend on them), be explicit that any timestamp can be an Astropy `Time`, since that is what we are ultimately working towards. Remove all mention of "UTC" and "Unix" in these cases, since any `Time` will do. --- katpoint/antenna.py | 8 +++---- katpoint/catalogue.py | 26 ++++++++++------------- katpoint/delay.py | 15 +++++++------ katpoint/target.py | 49 +++++++++++++++++++++---------------------- 4 files changed, 46 insertions(+), 52 deletions(-) diff --git a/katpoint/antenna.py b/katpoint/antenna.py index 3da96c9..5943474 100644 --- a/katpoint/antenna.py +++ b/katpoint/antenna.py @@ -319,15 +319,15 @@ def baseline_toward(self, antenna2): return ecef_to_enu(lat, lon, alt, *lla_to_ecef(*antenna2.position_wgs84)) def local_sidereal_time(self, timestamp=None): - """Calculate local sidereal time at antenna for timestamp(s). + """Calculate local apparent sidereal time at antenna for timestamp(s). This is a vectorised function that returns the local apparent sidereal - time at the antenna for a given UTC timestamp. + time at the antenna for the given timestamp(s). Parameters ---------- - timestamp : :class:`Timestamp` object or equivalent, optional - Timestamp(s) in UTC seconds since Unix epoch (defaults to now) + timestamp : :class:`~astropy.time.Time`, :class:`Timestamp` or equivalent, optional + Timestamp(s), defaults to now Returns ------- diff --git a/katpoint/catalogue.py b/katpoint/catalogue.py index f3db19b..28bb25c 100644 --- a/katpoint/catalogue.py +++ b/katpoint/catalogue.py @@ -606,9 +606,8 @@ def closest_to(self, target, timestamp=None, antenna=None): ---------- target : :class:`Target` object Target with which catalogue targets are compared - timestamp : :class:`Timestamp` object or equivalent, optional - Timestamp at which to evaluate target positions, in UTC seconds - since Unix epoch (defaults to now) + timestamp : :class:`~astropy.time.Time`, :class:`Timestamp` or equivalent, optional + Timestamp at which to evaluate target positions (defaults to now) antenna : :class:`Antenna` object, optional Antenna which points at targets (defaults to default antenna) @@ -667,9 +666,9 @@ def iterfilter(self, tags=None, flux_limit_Jy=None, flux_freq_MHz=None, az_limit takes the form [lower, upper]. If None, any distance is accepted. proximity_targets : :class:`Target` object, or sequence of objects Target or list of targets used in proximity filter - timestamp : :class:`Timestamp` object or equivalent, optional - Timestamp at which to evaluate target positions, in UTC seconds since - Unix epoch. If None, the current time *at each iteration* is used. + timestamp : :class:`~astropy.time.Time`, :class:`Timestamp` or equivalent, optional + Timestamp at which to evaluate target positions. + If None, the current time *at each iteration* is used. antenna : :class:`Antenna` object, optional Antenna which points at targets (defaults to default antenna) @@ -807,9 +806,8 @@ def filter(self, tags=None, flux_limit_Jy=None, flux_freq_MHz=None, az_limit_deg takes the form [lower, upper]. If None, any distance is accepted. proximity_targets : :class:`Target` object, or sequence of objects Target or list of targets used in proximity filter - timestamp : :class:`Timestamp` object or equivalent, optional - Timestamp at which to evaluate target positions, in UTC seconds - since Unix epoch (defaults to now) + timestamp : :class:`~astropy.time.Time`, :class:`Timestamp` or equivalent, optional + Timestamp at which to evaluate target positions (defaults to now) antenna : :class:`Antenna` object, optional Antenna which points at targets (defaults to default antenna) @@ -855,9 +853,8 @@ def sort(self, key='name', ascending=True, flux_freq_MHz=None, timestamp=None, a True if key should be sorted in ascending order flux_freq_MHz : float, optional Frequency at which to evaluate the flux density, in MHz - timestamp : :class:`Timestamp` object or equivalent, optional - Timestamp at which to evaluate target positions, in UTC seconds - since Unix epoch (defaults to now) + timestamp : :class:`~astropy.time.Time`, :class:`Timestamp` or equivalent, optional + Timestamp at which to evaluate target positions (defaults to now) antenna : :class:`Antenna` object, optional Antenna which points at targets (defaults to default antenna) @@ -910,9 +907,8 @@ def visibility_list(self, timestamp=None, antenna=None, flux_freq_MHz=None, ante Parameters ---------- - timestamp : :class:`Timestamp` object or equivalent, optional - Timestamp at which to evaluate target positions, in UTC seconds - since Unix epoch (defaults to now) + timestamp : :class:`~astropy.time.Time`, :class:`Timestamp` or equivalent, optional + Timestamp at which to evaluate target positions (defaults to now) antenna : :class:`Antenna` object, optional Antenna which points at targets (defaults to default antenna) flux_freq_MHz : float, optional diff --git a/katpoint/delay.py b/katpoint/delay.py index 93dfb81..79d224b 100644 --- a/katpoint/delay.py +++ b/katpoint/delay.py @@ -214,8 +214,8 @@ def _calculate_delays(self, target, timestamp, offset=None): ---------- target : :class:`Target` object Target providing direction for geometric delays - timestamp : :class:`Timestamp` object or equivalent - Timestamp in UTC seconds since Unix epoch + timestamp : :class:`~astropy.time.Time`, :class:`Timestamp` or equivalent + Timestamp when wavefront from target passes reference position offset : dict or None, optional Keyword arguments for :meth:`Target.plane_to_sphere` to offset delay centre relative to target (see method for details) @@ -284,12 +284,11 @@ def corrections(self, target, timestamp=None, next_timestamp=None, ---------- target : :class:`Target` object Target providing direction for geometric delays - timestamp : :class:`Timestamp` object or equivalent, or sequence, optional - Timestamp(s) in UTC seconds since Unix epoch when delays are - evaluated (default is now). If more than one timestamp is given, - the corrections will include slopes to be used for linear - interpolation between the times - next_timestamp : :class:`Timestamp` object or equivalent, optional + timestamp : :class:`~astropy.time.Time`, :class:`Timestamp` or equivalent, optional + Timestamp(s) when delays are evaluated (default is now). If more + than one timestamp is given, the corrections will include slopes + to be used for linear interpolation between the times. + next_timestamp : :class:`~astropy.time.Time`, :class:`Timestamp` or equivalent, optional Timestamp when next delay will be evaluated, used to determine a slope for linear interpolation (default is no slope). This is ignored if *timestamp* is a sequence. diff --git a/katpoint/target.py b/katpoint/target.py index 5f359c5..d34b981 100644 --- a/katpoint/target.py +++ b/katpoint/target.py @@ -316,8 +316,8 @@ def azel(self, timestamp=None, antenna=None): Parameters ---------- - timestamp : :class:`Timestamp` object or equivalent, optional - Timestamp(s) in UTC seconds since Unix epoch (defaults to now) + timestamp : :class:`~astropy.time.Time`, :class:`Timestamp` or equivalent, optional + Timestamp(s), defaults to now antenna : :class:`Antenna` object, optional Antenna which points at target (defaults to default antenna) @@ -348,8 +348,8 @@ def apparent_radec(self, timestamp=None, antenna=None): Parameters ---------- - timestamp : :class:`Timestamp` object or equivalent, optional - Timestamp(s) in UTC seconds since Unix epoch (defaults to now) + timestamp : :class:`~astropy.time.Time`, :class:`Timestamp` or equivalent, optional + Timestamp(s), defaults to now antenna : :class:`Antenna` object, optional Antenna which points at target (defaults to default antenna) @@ -377,8 +377,8 @@ def astrometric_radec(self, timestamp=None, antenna=None): Parameters ---------- - timestamp : :class:`Timestamp` object or equivalent, optional - Timestamp(s) in UTC seconds since Unix epoch (defaults to now) + timestamp : :class:`~astropy.time.Time`, :class:`Timestamp` or equivalent, optional + Timestamp(s), defaults to now antenna : :class:`Antenna` object, optional Antenna which points at target (defaults to default antenna) @@ -408,8 +408,8 @@ def galactic(self, timestamp=None, antenna=None): Parameters ---------- - timestamp : :class:`Timestamp` object or equivalent, optional - Timestamp(s) in UTC seconds since Unix epoch (defaults to now) + timestamp : :class:`~astropy.time.Time`, :class:`Timestamp` or equivalent, optional + Timestamp(s), defaults to now antenna : :class:`Antenna` object, optional Antenna which points at target (defaults to default antenna) @@ -440,8 +440,8 @@ def parallactic_angle(self, timestamp=None, antenna=None): Parameters ---------- - timestamp : :class:`Timestamp` object or equivalent, optional - Timestamp(s) in UTC seconds since Unix epoch (defaults to now) + timestamp : :class:`~astropy.time.Time`, :class:`Timestamp` or equivalent, optional + Timestamp(s), defaults to now antenna : :class:`Antenna` object, optional Antenna which points at target (defaults to default antenna) @@ -490,8 +490,8 @@ def geometric_delay(self, antenna2, timestamp=None, antenna=None): ---------- antenna2 : :class:`Antenna` object Second antenna of baseline pair (baseline vector points toward it) - timestamp : :class:`Timestamp` object or equivalent, optional - Timestamp(s) in UTC seconds since Unix epoch (defaults to now) + timestamp : :class:`~astropy.time.Time`, :class:`Timestamp` or equivalent, optional + Timestamp(s), defaults to now antenna : :class:`Antenna` object, optional First (reference) antenna of baseline pair, which also serves as pointing reference (defaults to default antenna) @@ -542,8 +542,8 @@ def uvw_basis(self, timestamp=None, antenna=None): Parameters ---------- - timestamp : :class:`Timestamp` object or equivalent, optional - Timestamp(s) in UTC seconds since Unix epoch (defaults to now) + timestamp : :class:`~astropy.time.Time`, :class:`Timestamp` or equivalent, optional + Timestamp(s), defaults to now antenna : :class:`Antenna` object, optional Reference antenna of baseline pairs, which also serves as pointing reference (defaults to default antenna) @@ -614,8 +614,8 @@ def uvw(self, antenna2, timestamp=None, antenna=None): ---------- antenna2 : :class:`Antenna` object or sequence Second antenna of baseline pair (baseline vector points toward it) - timestamp : :class:`Timestamp` object or equivalent, optional - Timestamp(s) in UTC seconds since Unix epoch (defaults to now) + timestamp : :class:`~astropy.time.Time`, :class:`Timestamp` or equivalent, optional + Timestamp(s), defaults to now antenna : :class:`Antenna` object, optional First (reference) antenna of baseline pair, which also serves as pointing reference (defaults to default antenna) @@ -663,8 +663,8 @@ def lmn(self, ra, dec, timestamp=None, antenna=None): Right ascension of the other target, in radians dec : float or array Declination of the other target, in radians - timestamp : :class:`Timestamp` object or equivalent, optional - Timestamp(s) in UTC seconds since Unix epoch (defaults to now) + timestamp : :class:`~astropy.time.Time`, :class:`Timestamp` or equivalent, optional + Timestamp(s), defaults to now antenna : :class:`Antenna` object, optional Pointing reference (defaults to default antenna) @@ -759,9 +759,8 @@ def separation(self, other_target, timestamp=None, antenna=None): ---------- other_target : :class:`Target` object The other target - timestamp : :class:`Timestamp` object or equivalent, optional - Timestamp(s) when separation is measured, in UTC seconds since Unix - epoch (defaults to now) + timestamp : :class:`~astropy.time.Time`, :class:`Timestamp` or equivalent, optional + Timestamp(s) when separation is measured (defaults to now) antenna : class:`Antenna` object, optional Antenna that observes both targets, from where separation is measured (defaults to default antenna of this target) @@ -796,8 +795,8 @@ def sphere_to_plane(self, az, el, timestamp=None, antenna=None, projection_type= Azimuth or right ascension, in radians el : float or array Elevation or declination, in radians - timestamp : :class:`Timestamp` object or equivalent, optional - Timestamp(s) in UTC seconds since Unix epoch (defaults to now) + timestamp : :class:`~astropy.time.Time`, :class:`Timestamp` or equivalent, optional + Timestamp(s), defaults to now antenna : :class:`Antenna` object, optional Antenna pointing at target (defaults to default antenna) projection_type : {'ARC', 'SIN', 'TAN', 'STG', 'CAR', 'SSN'}, optional @@ -836,8 +835,8 @@ def plane_to_sphere(self, x, y, timestamp=None, antenna=None, projection_type='A Azimuth-like coordinate(s) on plane, in radians y : float or array Elevation-like coordinate(s) on plane, in radians - timestamp : :class:`Timestamp` object or equivalent, optional - Timestamp(s) in UTC seconds since Unix epoch (defaults to now) + timestamp : :class:`~astropy.time.Time`, :class:`Timestamp` or equivalent, optional + Timestamp(s), defaults to now antenna : :class:`Antenna` object, optional Antenna pointing at target (defaults to default antenna) projection_type : {'ARC', 'SIN', 'TAN', 'STG', 'CAR', 'SSN'}, optional From 368adf2ece42c3e2a0eeca7a53f786e58594cad5 Mon Sep 17 00:00:00 2001 From: Ludwig Schwardt Date: Wed, 5 Aug 2020 16:04:10 +0200 Subject: [PATCH 064/122] Rework Body into abstract base class The base compute() had default values for `obstime` and `location` but the derived `StationaryBody` did not, which breaks the contract. Rework this so that the base compute() has no default but is also abstract. The implementation shifts to `FixedBody` (the main derived class that used it). Make `NullBody` a `FixedBody` to use this too. The base `Body` now only stores the name, which is better for dynamic bodies like `SolarSystemBody` and `EarthSatelliteBody` anyway. Other minor MR fixes: - FixedBody is specified to be ICRS, even if it accepts any sky coordinate. - Don't mention that SolarSystemBody computes GCRS positions (internal detail). --- katpoint/body.py | 74 ++++++++++++++++++++++++++++++++++-------------- 1 file changed, 52 insertions(+), 22 deletions(-) diff --git a/katpoint/body.py b/katpoint/body.py index d81cb64..2a66ae2 100644 --- a/katpoint/body.py +++ b/katpoint/body.py @@ -46,14 +46,48 @@ class Body: Parameters ---------- name : str - Name of celestial body - coord : :class:`~astropy.coordinates.BaseCoordinateFrame` or - :class:`~astropy.coordinates.SkyCoord`, optional - Coordinates of body (None if it is not fixed in any standard frame) + The name of the body """ - def __init__(self, name, coord=None): + def __init__(self, name): self.name = name + + def compute(self, frame, obstime, location): + """Compute the coordinates of the body in the requested frame. + + Parameters + ---------- + frame : str, :class:`~astropy.coordinates.BaseCoordinateFrame` class or + instance, or :class:`~astropy.coordinates.SkyCoord` instance + The frame to transform this body's coordinates into + obstime : :class:`~astropy.time.Time` + The time of observation + location : :class:`~astropy.coordinates.EarthLocation` + The location of the observer on the Earth + + Returns + ------- + coord : :class:`~astropy.coordinates.BaseCoordinateFrame` or + :class:`~astropy.coordinates.SkyCoord` + The computed coordinates as a new object + """ + raise NotImplementedError + + +class FixedBody(Body): + """A body with a fixed ICRS position. + + Parameters + ---------- + name : str + The name of the celestial body + coord : :class:`~astropy.coordinates.BaseCoordinateFrame` or + :class:`~astropy.coordinates.SkyCoord` + The coordinates of the body + """ + + def __init__(self, name, coord): + super().__init__(name) self.coord = coord def compute(self, frame, obstime=None, location=None): @@ -87,10 +121,6 @@ def compute(self, frame, obstime=None, location=None): coord = self.coord return coord.transform_to(frame) - -class FixedBody(Body): - """A body with a fixed position on the celestial sphere.""" - def writedb(self): """ Create an XEphem catalogue entry. @@ -106,19 +136,18 @@ class SolarSystemBody(Body): Parameters ---------- - name : str or other - The name of the body (see :func:`~astropy.coordinates.get_body` - for more details). + name : str + The name of the Solar System body """ def __init__(self, name): if name.lower() not in solar_system_ephemeris.bodies: raise ValueError("Unknown Solar System body '{}' - should be one of {}" .format(name.lower(), solar_system_ephemeris.bodies)) - super().__init__(name, None) + super().__init__(name) def compute(self, frame, obstime, location=None): - """Determine position of body in GCRS at given time and transform to `frame`.""" + """Determine position of body for given time and location and transform to `frame`.""" if isinstance(frame, AltAz) and frame.location is None: raise ValueError('Body needs a location to calculate (az, el) coordinates - ' 'did you specify an Antenna?') @@ -132,11 +161,11 @@ class EarthSatelliteBody(Body): Parameters ---------- name : str - Name of body + The name of the satellite """ def __init__(self, name): - super().__init__(name, None) + super().__init__(name) def compute(self, frame, obstime, location): """Determine position of body at the given time and transform to `frame`.""" @@ -380,14 +409,15 @@ class StationaryBody(Body): az, el : string or float Azimuth and elevation, either in 'D:M:S' string format, or float in rads name : string, optional - Name of body + The name of the stationary body """ def __init__(self, az, el, name=None): - super().__init__(name, AltAz(az=angle_from_degrees(az), alt=angle_from_degrees(el))) - if not self.name: - self.name = "Az: {} El: {}".format(self.coord.az.to_string(sep=':', unit=u.deg), - self.coord.alt.to_string(sep=':', unit=u.deg)) + self.coord = AltAz(az=angle_from_degrees(az), alt=angle_from_degrees(el)) + if not name: + name = "Az: {} El: {}".format(self.coord.az.to_string(sep=':', unit=u.deg), + self.coord.alt.to_string(sep=':', unit=u.deg)) + super().__init__(name) def compute(self, frame, obstime, location): """Transform (az, el) at given location and time to requested `frame`.""" @@ -406,7 +436,7 @@ def compute(self, frame, obstime, location): return altaz.transform_to(frame) -class NullBody(Body): +class NullBody(FixedBody): """Body with no position, used as a placeholder. This body has the expected methods of :class:`Body`, but always returns NaNs From b7fdc0b51c215a3ac879c09bf66cd4c304b774f4 Mon Sep 17 00:00:00 2001 From: Ludwig Schwardt Date: Thu, 6 Aug 2020 00:07:57 +0200 Subject: [PATCH 065/122] MR fixes - Clarify that a Body represents a single object. - Check AltAz location in a static utility method to avoid repetition. - Use a raw string for visibility_list docstring. - Use TimeDelta instead of converting seconds explicitly to days. --- katpoint/body.py | 19 +++++++++++++------ katpoint/catalogue.py | 2 +- katpoint/target.py | 4 ++-- 3 files changed, 16 insertions(+), 9 deletions(-) diff --git a/katpoint/body.py b/katpoint/body.py index 2a66ae2..19d8379 100644 --- a/katpoint/body.py +++ b/katpoint/body.py @@ -43,6 +43,10 @@ class Body: are computed on the fly, such as Solar System ephemerides and Earth satellites. + A Body represents a single celestial object with a scalar set of + coordinates at a given time instant, although the :meth:`compute` method + may return coordinates for multiple observation times. + Parameters ---------- name : str @@ -52,6 +56,13 @@ class Body: def __init__(self, name): self.name = name + @staticmethod + def _check_location(frame): + """Check that we have a location for computing AltAz coordinates.""" + if isinstance(frame, AltAz) and frame.location is None: + raise ValueError('Body needs a location to calculate (az, el) coordinates - ' + 'did you specify an Antenna?') + def compute(self, frame, obstime, location): """Compute the coordinates of the body in the requested frame. @@ -109,9 +120,7 @@ def compute(self, frame, obstime=None, location=None): :class:`~astropy.coordinates.SkyCoord` The computed coordinates as a new object """ - if isinstance(frame, AltAz) and frame.location is None: - raise ValueError('Body needs a location to calculate (az, el) coordinates - ' - 'did you specify an Antenna?') + Body._check_location(frame) # If obstime is array-valued and not contained in the output frame, the transform # will return a scalar SkyCoord. Repeat the value to match obstime shape instead. if (obstime is not None and not obstime.isscalar @@ -148,9 +157,7 @@ def __init__(self, name): def compute(self, frame, obstime, location=None): """Determine position of body for given time and location and transform to `frame`.""" - if isinstance(frame, AltAz) and frame.location is None: - raise ValueError('Body needs a location to calculate (az, el) coordinates - ' - 'did you specify an Antenna?') + Body._check_location(frame) gcrs = get_body(self.name, obstime, location) return gcrs.transform_to(frame) diff --git a/katpoint/catalogue.py b/katpoint/catalogue.py index 28bb25c..1622a34 100644 --- a/katpoint/catalogue.py +++ b/katpoint/catalogue.py @@ -891,7 +891,7 @@ def sort(self, key='name', ascending=True, flux_freq_MHz=None, timestamp=None, a return self def visibility_list(self, timestamp=None, antenna=None, flux_freq_MHz=None, antenna2=None): - """Print out list of targets in catalogue, sorted by decreasing elevation. + r"""Print out list of targets in catalogue, sorted by decreasing elevation. This prints out the name, azimuth and elevation of each target in the catalogue, in order of decreasing elevation. The motion of the target at diff --git a/katpoint/target.py b/katpoint/target.py index d34b981..1a47976 100644 --- a/katpoint/target.py +++ b/katpoint/target.py @@ -23,7 +23,7 @@ from astropy.coordinates import Latitude, Longitude, Angle # Angles from astropy.time import Time -from .timestamp import Timestamp +from .timestamp import Timestamp, delta_seconds from .flux import FluxDensityModel from .ephem_extra import (is_iterable, lightspeed, deg2rad, angle_from_degrees, angle_from_hours) from .conversion import azel_to_enu @@ -521,7 +521,7 @@ def geometric_delay(self, antenna2, timestamp=None, antenna=None): baseline_m = antenna.baseline_toward(antenna2) # Obtain direction vector(s) from reference antenna to target, and numerically # estimate delay rate from difference across 1-second interval spanning timestamp(s) - times = time[..., np.newaxis] + np.array((-0.5, 0.0, 0.5)) * u.s.to(u.day) + times = time[..., np.newaxis] + delta_seconds([-0.5, 0.0, 0.5]) azel = self.azel(times, antenna) targetdirs = np.array(azel_to_enu(azel.az.rad, azel.alt.rad)) # Dot product of vectors is w coordinate, and From d8e97f886a3d9cf90e562ab248537df59cb53b92 Mon Sep 17 00:00:00 2001 From: Ludwig Schwardt Date: Thu, 6 Aug 2020 00:23:35 +0200 Subject: [PATCH 066/122] Split test class into functions Turn the class attributes into module constants to share between more tests. --- katpoint/test/test_target.py | 292 ++++++++++++++++++----------------- 1 file changed, 149 insertions(+), 143 deletions(-) diff --git a/katpoint/test/test_target.py b/katpoint/test/test_target.py index 550fd8c..5c367bc 100644 --- a/katpoint/test/test_target.py +++ b/katpoint/test/test_target.py @@ -216,154 +216,160 @@ def test_coord_methods_without_antenna(description, methods, raises, error): getattr(target, method)() +TARGET = katpoint.construct_azel_target('45:00:00.0', '75:00:00.0') +ANT1 = katpoint.Antenna('A1, -31.0, 18.0, 0.0, 12.0, 0.0 0.0 0.0') +ANT2 = katpoint.Antenna('A2, -31.0, 18.0, 0.0, 12.0, 10.0 -10.0 0.0') +TS = katpoint.Timestamp('2013-08-14 09:25') + + # XXX TLE_TARGET does not support array timestamps yet @pytest.mark.parametrize("description", ['azel, 10, -10', 'radec, 20, -20', 'gal, 30, -30', 'Sun, special']) def test_array_valued_azel(description): """Test array-valued (az, el) coordinates.""" - ts = katpoint.Timestamp('2020-07-30 14:02:00') - ant1 = katpoint.Antenna('A1, -31.0, 18.0, 0.0, 12.0, 0.0 0.0 0.0') offsets = np.array([np.arange(3), np.arange(3)]) - times = ts + offsets + times = katpoint.Timestamp('2020-07-30 14:02:00') + offsets assert times.time.shape == offsets.shape target = katpoint.Target(description) - assert target.azel(times, ant1).shape == offsets.shape - assert target.astrometric_radec(times, ant1).shape == offsets.shape - assert target.apparent_radec(times, ant1).shape == offsets.shape - assert target.galactic(times, ant1).shape == offsets.shape - assert target.parallactic_angle(times, ant1).shape == offsets.shape - assert target.separation(target, times, ant1).shape == offsets.shape - - -class TestTargetCalculations: - """Test various calculations involving antennas and timestamps.""" - - def setup(self): - self.target = katpoint.construct_azel_target('45:00:00.0', '75:00:00.0') - self.ant1 = katpoint.Antenna('A1, -31.0, 18.0, 0.0, 12.0, 0.0 0.0 0.0') - self.ant2 = katpoint.Antenna('A2, -31.0, 18.0, 0.0, 12.0, 10.0 -10.0 0.0') - self.ts = katpoint.Timestamp('2013-08-14 09:25') - # self.uvw = [10.822861713680807, -9.103057965680664, -2.220446049250313e-16] - self.uvw = [10.820796672358002, -9.1055125816993954, -2.22044604925e-16] - - def test_coords(self): - """Test coordinate conversions for coverage and verification.""" - coord = self.target.azel(self.ts, self.ant1) - assert coord.az.deg == 45 # PyEphem: 45 - assert coord.alt.deg == 75 # PyEphem: 75 - coord = self.target.apparent_radec(self.ts, self.ant1) - ra_hour = coord.ra.to_string(unit='hour', sep=':', precision=8) - dec_deg = coord.dec.to_string(sep=':', precision=8) - assert ra_hour == '8:53:03.49166920' # PyEphem: 8:53:09.60 (same as astrometric) - assert dec_deg == '-19:54:51.92328722' # PyEphem: -19:51:43.0 (same as astrometric) - coord = self.target.astrometric_radec(self.ts, self.ant1) - ra_hour = coord.ra.to_string(unit='hour', sep=':', precision=8) - dec_deg = coord.dec.to_string(sep=':', precision=8) - assert ra_hour == '8:53:09.60397465' # PyEphem: 8:53:09.60 - assert dec_deg == '-19:51:42.87773802' # PyEphem: -19:51:43.0 - coord = self.target.galactic(self.ts, self.ant1) - l_deg = coord.l.to_string(sep=':', precision=8) - b_deg = coord.b.to_string(sep=':', precision=8) - assert l_deg == '245:34:49.20442837' # PyEphem: 245:34:49.3 - assert b_deg == '15:36:24.87974969' # PyEphem: 15:36:24.7 - coord = self.target.parallactic_angle(self.ts, self.ant1) - assert coord.deg == pytest.approx(-140.279593566336) # PyEphem: -140.34440985011398 - - def test_delay(self): - """Test geometric delay.""" - delay, delay_rate = self.target.geometric_delay(self.ant2, self.ts, self.ant1) - np.testing.assert_almost_equal(delay, 0.0, decimal=12) - np.testing.assert_almost_equal(delay_rate, 0.0, decimal=12) - delay, delay_rate = self.target.geometric_delay(self.ant2, [self.ts, self.ts], self.ant1) - np.testing.assert_almost_equal(delay, np.array([0.0, 0.0]), decimal=12) - np.testing.assert_almost_equal(delay_rate, np.array([0.0, 0.0]), decimal=12) - - def test_uvw(self): - """Test uvw calculation.""" - u, v, w = self.target.uvw(self.ant2, self.ts, self.ant1) - np.testing.assert_almost_equal(u, self.uvw[0], decimal=5) - np.testing.assert_almost_equal(v, self.uvw[1], decimal=5) - np.testing.assert_almost_equal(w, self.uvw[2], decimal=5) - - def test_uvw_timestamp_array(self): - """Test uvw calculation on an array.""" - u, v, w = self.target.uvw(self.ant2, np.array([self.ts, self.ts]), self.ant1) - np.testing.assert_array_almost_equal(u, np.array([self.uvw[0]] * 2), decimal=5) - np.testing.assert_array_almost_equal(v, np.array([self.uvw[1]] * 2), decimal=5) - np.testing.assert_array_almost_equal(w, np.array([self.uvw[2]] * 2), decimal=5) - - def test_uvw_timestamp_array_radec(self): - """Test uvw calculation on a timestamp array when the target is a radec target.""" - radec = self.target.radec(self.ts, self.ant1) - target = katpoint.construct_radec_target(radec.ra, radec.dec) - u, v, w = target.uvw(self.ant2, np.array([self.ts, self.ts]), self.ant1) - np.testing.assert_array_almost_equal(u, np.array([self.uvw[0]] * 2), decimal=4) - np.testing.assert_array_almost_equal(v, np.array([self.uvw[1]] * 2), decimal=4) - np.testing.assert_array_almost_equal(w, np.array([self.uvw[2]] * 2), decimal=4) - - def test_uvw_antenna_array(self): - u, v, w = self.target.uvw([self.ant1, self.ant2], self.ts, self.ant1) - np.testing.assert_array_almost_equal(u, np.array([0, self.uvw[0]]), decimal=5) - np.testing.assert_array_almost_equal(v, np.array([0, self.uvw[1]]), decimal=5) - np.testing.assert_array_almost_equal(w, np.array([0, self.uvw[2]]), decimal=5) - - def test_uvw_both_array(self): - u, v, w = self.target.uvw([self.ant1, self.ant2], [self.ts, self.ts], self.ant1) - np.testing.assert_array_almost_equal(u, np.array([[0, self.uvw[0]]] * 2), decimal=5) - np.testing.assert_array_almost_equal(v, np.array([[0, self.uvw[1]]] * 2), decimal=5) - np.testing.assert_array_almost_equal(w, np.array([[0, self.uvw[2]]] * 2), decimal=5) - - def test_uvw_hemispheres(self): - """Test uvw calculation near the equator. - - The implementation behaves differently depending on the sign of - declination. This test is to catch sign flip errors. - """ - target1 = katpoint.construct_radec_target(0.0, -1e-9) - target2 = katpoint.construct_radec_target(0.0, +1e-9) - u1, v1, w1 = target1.uvw(self.ant2, self.ts, self.ant1) - u2, v2, w2 = target2.uvw(self.ant2, self.ts, self.ant1) - np.testing.assert_almost_equal(u1, u2, decimal=3) - np.testing.assert_almost_equal(v1, v2, decimal=3) - np.testing.assert_almost_equal(w1, w2, decimal=3) - - def test_lmn(self): - """Test lmn calculation.""" - # For angles less than pi/2, it matches SIN projection - pointing = katpoint.construct_radec_target('11:00:00.0', '-75:00:00.0') - target = katpoint.construct_radec_target('16:00:00.0', '-65:00:00.0') - radec = target.radec(timestamp=self.ts, antenna=self.ant1) - l, m, n = pointing.lmn(radec.ra.rad, radec.dec.rad) - expected_l, expected_m = pointing.sphere_to_plane( - radec.ra.rad, radec.dec.rad, projection_type='SIN', coord_system='radec') - expected_n = np.sqrt(1.0 - expected_l**2 - expected_m**2) - np.testing.assert_almost_equal(l, expected_l, decimal=12) - np.testing.assert_almost_equal(m, expected_m, decimal=12) - np.testing.assert_almost_equal(n, expected_n, decimal=12) - # Test angle > pi/2: using the diametrically opposite target - l, m, n = pointing.lmn(np.pi + radec.ra.rad, -radec.dec.rad) - np.testing.assert_almost_equal(l, -expected_l, decimal=12) - np.testing.assert_almost_equal(m, -expected_m, decimal=12) - np.testing.assert_almost_equal(n, -expected_n, decimal=12) - - def test_separation(self): - """Test separation calculation.""" - sun = katpoint.Target('Sun, special') - azel_sun = sun.azel(self.ts, self.ant1) - azel = katpoint.construct_azel_target(azel_sun.az, azel_sun.alt) - sep = sun.separation(azel, self.ts, self.ant1) - np.testing.assert_almost_equal(sep.rad, 0.0) - sep = azel.separation(sun, self.ts, self.ant1) - np.testing.assert_almost_equal(sep.rad, 0.0) - azel2 = katpoint.construct_azel_target(azel_sun.az, - azel_sun.alt + Angle(0.01, unit=u.rad)) - sep = azel.separation(azel2, self.ts, self.ant1) - np.testing.assert_almost_equal(sep.rad, 0.01, decimal=7) - - def test_projection(self): - """Test projection.""" - az, el = katpoint.deg2rad(50.0), katpoint.deg2rad(80.0) - x, y = self.target.sphere_to_plane(az, el, self.ts, self.ant1) - re_az, re_el = self.target.plane_to_sphere(x, y, self.ts, self.ant1) - np.testing.assert_almost_equal(re_az, az, decimal=12) - np.testing.assert_almost_equal(re_el, el, decimal=12) + assert target.azel(times, ANT1).shape == offsets.shape + assert target.astrometric_radec(times, ANT1).shape == offsets.shape + assert target.apparent_radec(times, ANT1).shape == offsets.shape + assert target.galactic(times, ANT1).shape == offsets.shape + assert target.parallactic_angle(times, ANT1).shape == offsets.shape + assert target.separation(target, times, ANT1).shape == offsets.shape + + +def test_coords(): + """Test coordinate conversions for coverage and verification.""" + coord = TARGET.azel(TS, ANT1) + assert coord.az.deg == 45 # PyEphem: 45 + assert coord.alt.deg == 75 # PyEphem: 75 + coord = TARGET.apparent_radec(TS, ANT1) + ra_hour = coord.ra.to_string(unit='hour', sep=':', precision=8) + dec_deg = coord.dec.to_string(sep=':', precision=8) + assert ra_hour == '8:53:03.49166920' # PyEphem: 8:53:09.60 (same as astrometric) + assert dec_deg == '-19:54:51.92328722' # PyEphem: -19:51:43.0 (same as astrometric) + coord = TARGET.astrometric_radec(TS, ANT1) + ra_hour = coord.ra.to_string(unit='hour', sep=':', precision=8) + dec_deg = coord.dec.to_string(sep=':', precision=8) + assert ra_hour == '8:53:09.60397465' # PyEphem: 8:53:09.60 + assert dec_deg == '-19:51:42.87773802' # PyEphem: -19:51:43.0 + coord = TARGET.galactic(TS, ANT1) + l_deg = coord.l.to_string(sep=':', precision=8) + b_deg = coord.b.to_string(sep=':', precision=8) + assert l_deg == '245:34:49.20442837' # PyEphem: 245:34:49.3 + assert b_deg == '15:36:24.87974969' # PyEphem: 15:36:24.7 + coord = TARGET.parallactic_angle(TS, ANT1) + assert coord.deg == pytest.approx(-140.279593566336) # PyEphem: -140.34440985011398 + + +def test_delay(): + """Test geometric delay.""" + delay, delay_rate = TARGET.geometric_delay(ANT2, TS, ANT1) + np.testing.assert_almost_equal(delay, 0.0, decimal=12) + np.testing.assert_almost_equal(delay_rate, 0.0, decimal=12) + delay, delay_rate = TARGET.geometric_delay(ANT2, [TS, TS], ANT1) + np.testing.assert_almost_equal(delay, np.array([0.0, 0.0]), decimal=12) + np.testing.assert_almost_equal(delay_rate, np.array([0.0, 0.0]), decimal=12) + + +UVW = [10.820796672358002, -9.1055125816993954, -2.22044604925e-16] + + +def test_uvw(): + """Test uvw calculation.""" + u, v, w = TARGET.uvw(ANT2, TS, ANT1) + np.testing.assert_almost_equal(u, UVW[0], decimal=5) + np.testing.assert_almost_equal(v, UVW[1], decimal=5) + np.testing.assert_almost_equal(w, UVW[2], decimal=5) + + +def test_uvw_timestamp_array(): + """Test uvw calculation on an array.""" + u, v, w = TARGET.uvw(ANT2, np.array([TS, TS]), ANT1) + np.testing.assert_array_almost_equal(u, np.array([UVW[0]] * 2), decimal=5) + np.testing.assert_array_almost_equal(v, np.array([UVW[1]] * 2), decimal=5) + np.testing.assert_array_almost_equal(w, np.array([UVW[2]] * 2), decimal=5) + + +def test_uvw_timestamp_array_radec(): + """Test uvw calculation on a timestamp array when the target is a radec target.""" + radec = TARGET.radec(TS, ANT1) + target = katpoint.construct_radec_target(radec.ra, radec.dec) + u, v, w = target.uvw(ANT2, np.array([TS, TS]), ANT1) + np.testing.assert_array_almost_equal(u, np.array([UVW[0]] * 2), decimal=4) + np.testing.assert_array_almost_equal(v, np.array([UVW[1]] * 2), decimal=4) + np.testing.assert_array_almost_equal(w, np.array([UVW[2]] * 2), decimal=4) + + +def test_uvw_antenna_array(): + u, v, w = TARGET.uvw([ANT1, ANT2], TS, ANT1) + np.testing.assert_array_almost_equal(u, np.array([0, UVW[0]]), decimal=5) + np.testing.assert_array_almost_equal(v, np.array([0, UVW[1]]), decimal=5) + np.testing.assert_array_almost_equal(w, np.array([0, UVW[2]]), decimal=5) + + +def test_uvw_both_array(): + u, v, w = TARGET.uvw([ANT1, ANT2], [TS, TS], ANT1) + np.testing.assert_array_almost_equal(u, np.array([[0, UVW[0]]] * 2), decimal=5) + np.testing.assert_array_almost_equal(v, np.array([[0, UVW[1]]] * 2), decimal=5) + np.testing.assert_array_almost_equal(w, np.array([[0, UVW[2]]] * 2), decimal=5) + + +def test_uvw_hemispheres(): + """Test uvw calculation near the equator. + + The implementation behaves differently depending on the sign of + declination. This test is to catch sign flip errors. + """ + target1 = katpoint.construct_radec_target(0.0, -1e-9) + target2 = katpoint.construct_radec_target(0.0, +1e-9) + u1, v1, w1 = target1.uvw(ANT2, TS, ANT1) + u2, v2, w2 = target2.uvw(ANT2, TS, ANT1) + np.testing.assert_almost_equal(u1, u2, decimal=3) + np.testing.assert_almost_equal(v1, v2, decimal=3) + np.testing.assert_almost_equal(w1, w2, decimal=3) + + +def test_lmn(): + """Test lmn calculation.""" + # For angles less than pi/2, it matches SIN projection + pointing = katpoint.construct_radec_target('11:00:00.0', '-75:00:00.0') + target = katpoint.construct_radec_target('16:00:00.0', '-65:00:00.0') + radec = target.radec(timestamp=TS, antenna=ANT1) + l, m, n = pointing.lmn(radec.ra.rad, radec.dec.rad) + expected_l, expected_m = pointing.sphere_to_plane( + radec.ra.rad, radec.dec.rad, projection_type='SIN', coord_system='radec') + expected_n = np.sqrt(1.0 - expected_l**2 - expected_m**2) + np.testing.assert_almost_equal(l, expected_l, decimal=12) + np.testing.assert_almost_equal(m, expected_m, decimal=12) + np.testing.assert_almost_equal(n, expected_n, decimal=12) + # Test angle > pi/2: using the diametrically opposite target + l, m, n = pointing.lmn(np.pi + radec.ra.rad, -radec.dec.rad) + np.testing.assert_almost_equal(l, -expected_l, decimal=12) + np.testing.assert_almost_equal(m, -expected_m, decimal=12) + np.testing.assert_almost_equal(n, -expected_n, decimal=12) + + +def test_separation(): + """Test separation calculation.""" + sun = katpoint.Target('Sun, special') + azel_sun = sun.azel(TS, ANT1) + azel = katpoint.construct_azel_target(azel_sun.az, azel_sun.alt) + sep = sun.separation(azel, TS, ANT1) + np.testing.assert_almost_equal(sep.rad, 0.0) + sep = azel.separation(sun, TS, ANT1) + np.testing.assert_almost_equal(sep.rad, 0.0) + azel2 = katpoint.construct_azel_target(azel_sun.az, + azel_sun.alt + Angle(0.01, unit=u.rad)) + sep = azel.separation(azel2, TS, ANT1) + np.testing.assert_almost_equal(sep.rad, 0.01, decimal=7) + + +def test_projection(): + """Test projection.""" + az, el = katpoint.deg2rad(50.0), katpoint.deg2rad(80.0) + x, y = TARGET.sphere_to_plane(az, el, TS, ANT1) + re_az, re_el = TARGET.plane_to_sphere(x, y, TS, ANT1) + np.testing.assert_almost_equal(re_az, az, decimal=12) + np.testing.assert_almost_equal(re_el, el, decimal=12) From b750a82e856c108256cb4be5703720691d6c67a1 Mon Sep 17 00:00:00 2001 From: Ludwig Schwardt Date: Thu, 6 Aug 2020 15:31:51 +0200 Subject: [PATCH 067/122] Handle multi-dimensional times in uvw_basis First ravel the times and then reshape the output. This is a stop-gap measure until we get around to full vectorisation of this function. Extend the array-valued tests to check more methods (which is how I picked up the uvw_basis problem). --- katpoint/target.py | 4 ++-- katpoint/test/test_target.py | 17 +++++++++++++---- 2 files changed, 15 insertions(+), 6 deletions(-) diff --git a/katpoint/target.py b/katpoint/target.py index 1a47976..3dffa95 100644 --- a/katpoint/target.py +++ b/katpoint/target.py @@ -562,8 +562,8 @@ def uvw_basis(self, timestamp=None, antenna=None): if not time.isscalar and self.body_type != 'radec': # Some calculations depend on ra/dec in a way that won't easily # vectorise. - bases = [self.uvw_basis(t, antenna) for t in time] - return np.stack(bases, axis=-1) + bases = [self.uvw_basis(t, antenna) for t in time.ravel()] + return np.stack(bases, axis=-1).reshape(3, 3, *time.shape) # Offset the target slightly in declination to approximate the # derivative of ENU in the direction of increasing declination. This diff --git a/katpoint/test/test_target.py b/katpoint/test/test_target.py index 5c367bc..25bc9de 100644 --- a/katpoint/test/test_target.py +++ b/katpoint/test/test_target.py @@ -225,17 +225,26 @@ def test_coord_methods_without_antenna(description, methods, raises, error): # XXX TLE_TARGET does not support array timestamps yet @pytest.mark.parametrize("description", ['azel, 10, -10', 'radec, 20, -20', 'gal, 30, -30', 'Sun, special']) -def test_array_valued_azel(description): - """Test array-valued (az, el) coordinates.""" - offsets = np.array([np.arange(3), np.arange(3)]) +def test_array_valued_methods(description): + """Test array-valued methods (at least their output shapes).""" + offsets = np.array([[0, 1, 2, 3]]) times = katpoint.Timestamp('2020-07-30 14:02:00') + offsets assert times.time.shape == offsets.shape target = katpoint.Target(description) assert target.azel(times, ANT1).shape == offsets.shape - assert target.astrometric_radec(times, ANT1).shape == offsets.shape assert target.apparent_radec(times, ANT1).shape == offsets.shape + radec = target.astrometric_radec(times, ANT1) + assert radec.shape == offsets.shape assert target.galactic(times, ANT1).shape == offsets.shape assert target.parallactic_angle(times, ANT1).shape == offsets.shape + delay, delay_rate = target.geometric_delay(ANT2, times, ANT1) + assert delay.shape == offsets.shape + assert delay_rate.shape == offsets.shape + assert target.uvw_basis(times, ANT1).shape == (3, 3) + offsets.shape + u, v, w = target.uvw([ANT1, ANT2], times, ANT1) + assert u.shape == v.shape == w.shape == offsets.shape + (2,) + l, m, n = target.lmn(radec.ra.rad, radec.dec.rad, times, ANT1) + assert l.shape == m.shape == n.shape == offsets.shape assert target.separation(target, times, ANT1).shape == offsets.shape From 7e5ca9595bed15d2dc0154c7f86f15b649a32f59 Mon Sep 17 00:00:00 2001 From: Ludwig Schwardt Date: Tue, 11 Aug 2020 00:05:14 +0200 Subject: [PATCH 068/122] Let uvw_basis handle multi-dimensional timestamps The row_stack and reshape steps hark back to the original katpoint scenario of a scalar timestamp or at most a sequence of timestamps. It turns out that they are actually superfluous in those cases and also actively hurt the full multi-dimensional case, so remove them. --- katpoint/target.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/katpoint/target.py b/katpoint/target.py index 3dffa95..ff8c87c 100644 --- a/katpoint/target.py +++ b/katpoint/target.py @@ -592,11 +592,10 @@ def uvw_basis(self, timestamp=None, antenna=None): azel = self.azel(time, antenna) # w axis points toward target w = np.array(azel_to_enu(azel.az.rad, azel.alt.rad)) - # u axis is orthogonal to z and w, and row_stack makes it 2-D array of column vectors - u = np.row_stack(np.cross(z, w, axis=0)) * offset_sign - u_norm = np.sqrt(np.sum(u ** 2, axis=0)) - # Ensure that u and w (and therefore v) have the same shape to handle scalar vs array output correctly - u = u.reshape(w.shape) / u_norm + # u axis is orthogonal to z and w + u = np.cross(z, w, axis=0) * offset_sign + u /= np.linalg.norm(u, axis=0) + # v axis completes the orthonormal basis v = np.cross(w, u, axis=0) return np.array([u, v, w]) From 213d4c2ff3e695ca050a1e6a91ca41bf8048d4cc Mon Sep 17 00:00:00 2001 From: Ludwig Schwardt Date: Tue, 11 Aug 2020 11:37:06 +0200 Subject: [PATCH 069/122] Simplify some tests The second TLE target is meant to test the case of an empty name (as opposed to a missing name). It might as well be the same satellite as the first one. --- katpoint/test/test_target.py | 33 ++++++++++----------------------- 1 file changed, 10 insertions(+), 23 deletions(-) diff --git a/katpoint/test/test_target.py b/katpoint/test/test_target.py index 25bc9de..b667864 100644 --- a/katpoint/test/test_target.py +++ b/katpoint/test/test_target.py @@ -81,31 +81,23 @@ def test_constructed_coords(self): # azel azel = katpoint.Target(self.azel_target) calc_azel = azel.azel() - calc_az = calc_azel.az - calc_el = calc_azel.alt - assert calc_az.deg == 10.0 - assert calc_el.deg == -10.0 + assert calc_azel.az.deg == 10.0 + assert calc_azel.alt.deg == -10.0 # radec (degrees) radec = katpoint.Target(self.radec_target) calc_radec = radec.radec() - calc_ra = calc_radec.ra - calc_dec = calc_radec.dec - assert calc_ra.deg == 20.0 - assert calc_dec.deg == -20.0 + assert calc_radec.ra.deg == 20.0 + assert calc_radec.dec.deg == -20.0 # radec (hours) radec_rahours = katpoint.Target(self.radec_target_rahours) calc_radec_rahours = radec_rahours.radec() - calc_ra = calc_radec_rahours.ra - calc_dec = calc_radec_rahours.dec - assert calc_ra.hms == (20, 0, 0) - assert calc_dec.deg == -20.0 + assert calc_radec_rahours.ra.hms == (20, 0, 0) + assert calc_radec_rahours.dec.deg == -20.0 # gal lb = katpoint.Target(self.gal_target) calc_lb = lb.galactic() - calc_l = calc_lb.l - calc_b = calc_lb.b - assert calc_l.deg == 30.0 - assert calc_b.deg == -30.0 + assert calc_lb.l.deg == 30.0 + assert calc_lb.b.deg == -30.0 def test_add_tags(self): """Test adding tags.""" @@ -131,10 +123,7 @@ def test_add_tags(self): 'Zizou, radec cal, 1.4, 30.0, (1000.0 2000.0 1.0 10.0)', 'Fluffy | *Dinky, radec, 12.5, -50.0, (1.0 2.0 1.0 2.0 3.0 4.0)', TLE_TARGET, - (', tle, GPS BIIA-22 (PRN 05) \n' - '1 22779U 93054A {:02d}266.92814765 .00000062 00000-0 10000-3 0 289{:1d}\n' - '2 22779 53.8943 118.4708 0081407 68.2645 292.7207 2.00558015103055\n' - .format(YY, (YY // 10 + YY - 7 + 5) % 10)), + ', ' + TLE_TARGET, 'Sun, special', 'Nothing, special', 'Moon | Luna, special solarbody', @@ -288,9 +277,7 @@ def test_delay(): def test_uvw(): """Test uvw calculation.""" u, v, w = TARGET.uvw(ANT2, TS, ANT1) - np.testing.assert_almost_equal(u, UVW[0], decimal=5) - np.testing.assert_almost_equal(v, UVW[1], decimal=5) - np.testing.assert_almost_equal(w, UVW[2], decimal=5) + np.testing.assert_almost_equal([u, v, w], UVW, decimal=5) def test_uvw_timestamp_array(): From 1cd0a4a106c43d7cd1e3a4d25162d6c1f19b184e Mon Sep 17 00:00:00 2001 From: Ludwig Schwardt Date: Tue, 11 Aug 2020 15:07:01 +0200 Subject: [PATCH 070/122] Rework delay and uvw tests to use radec target An radec target has non-trivial delays, so check the actual values. Use two distinct timestamps to find problems along the time axis. Check the entire output array in one go instead of looping over u, v, w. --- katpoint/test/test_target.py | 65 +++++++++++++++++------------------- 1 file changed, 30 insertions(+), 35 deletions(-) diff --git a/katpoint/test/test_target.py b/katpoint/test/test_target.py index b667864..e7fd876 100644 --- a/katpoint/test/test_target.py +++ b/katpoint/test/test_target.py @@ -261,55 +261,50 @@ def test_coords(): assert coord.deg == pytest.approx(-140.279593566336) # PyEphem: -140.34440985011398 -def test_delay(): - """Test geometric delay.""" - delay, delay_rate = TARGET.geometric_delay(ANT2, TS, ANT1) - np.testing.assert_almost_equal(delay, 0.0, decimal=12) - np.testing.assert_almost_equal(delay_rate, 0.0, decimal=12) - delay, delay_rate = TARGET.geometric_delay(ANT2, [TS, TS], ANT1) - np.testing.assert_almost_equal(delay, np.array([0.0, 0.0]), decimal=12) - np.testing.assert_almost_equal(delay_rate, np.array([0.0, 0.0]), decimal=12) +DELAY_TARGET = katpoint.Target('radec, 20.0, -20.0') +DELAY_TS = [TS, TS + 1.0] +DELAY = [1.75538294e-08, 1.75522002e-08] +DELAY_RATE = [-1.62915174e-12, -1.62929689e-12] +UVW = ([-7.118580813334029, -11.028682662045913, -5.262505671628351], + [-7.119215642091996, -11.028505936045280, -5.262017242465739]) -UVW = [10.820796672358002, -9.1055125816993954, -2.22044604925e-16] +def test_delay(): + """Test geometric delay.""" + delay, delay_rate = DELAY_TARGET.geometric_delay(ANT2, DELAY_TS[0], ANT1) + np.testing.assert_allclose(delay, DELAY[0]) + np.testing.assert_allclose(delay_rate, DELAY_RATE[0]) + delay, delay_rate = DELAY_TARGET.geometric_delay(ANT2, DELAY_TS, ANT1) + np.testing.assert_allclose(delay, DELAY) + np.testing.assert_allclose(delay_rate, DELAY_RATE) def test_uvw(): """Test uvw calculation.""" - u, v, w = TARGET.uvw(ANT2, TS, ANT1) - np.testing.assert_almost_equal([u, v, w], UVW, decimal=5) - - -def test_uvw_timestamp_array(): - """Test uvw calculation on an array.""" - u, v, w = TARGET.uvw(ANT2, np.array([TS, TS]), ANT1) - np.testing.assert_array_almost_equal(u, np.array([UVW[0]] * 2), decimal=5) - np.testing.assert_array_almost_equal(v, np.array([UVW[1]] * 2), decimal=5) - np.testing.assert_array_almost_equal(w, np.array([UVW[2]] * 2), decimal=5) + u, v, w = DELAY_TARGET.uvw(ANT2, DELAY_TS[0], ANT1) + np.testing.assert_almost_equal([u, v, w], UVW[0], decimal=5) + u, v, w = DELAY_TARGET.uvw(ANT2, DELAY_TS, ANT1) + np.testing.assert_array_almost_equal([u, v, w], np.c_[UVW], decimal=5) -def test_uvw_timestamp_array_radec(): - """Test uvw calculation on a timestamp array when the target is a radec target.""" - radec = TARGET.radec(TS, ANT1) - target = katpoint.construct_radec_target(radec.ra, radec.dec) - u, v, w = target.uvw(ANT2, np.array([TS, TS]), ANT1) - np.testing.assert_array_almost_equal(u, np.array([UVW[0]] * 2), decimal=4) - np.testing.assert_array_almost_equal(v, np.array([UVW[1]] * 2), decimal=4) - np.testing.assert_array_almost_equal(w, np.array([UVW[2]] * 2), decimal=4) +def test_uvw_timestamp_array_azel(): + """Test uvw calculation on a timestamp array when the target is an azel target.""" + azel = DELAY_TARGET.azel(DELAY_TS[0], ANT1) + target = katpoint.construct_azel_target(azel.az, azel.alt) + u, v, w = target.uvw(ANT2, DELAY_TS, ANT1) + np.testing.assert_array_almost_equal([u, v, w], np.c_[(UVW[0],) * len(DELAY_TS)], decimal=4) def test_uvw_antenna_array(): - u, v, w = TARGET.uvw([ANT1, ANT2], TS, ANT1) - np.testing.assert_array_almost_equal(u, np.array([0, UVW[0]]), decimal=5) - np.testing.assert_array_almost_equal(v, np.array([0, UVW[1]]), decimal=5) - np.testing.assert_array_almost_equal(w, np.array([0, UVW[2]]), decimal=5) + u, v, w = DELAY_TARGET.uvw([ANT1, ANT2], DELAY_TS[0], ANT1) + np.testing.assert_array_almost_equal([u, v, w], np.c_[np.zeros(3), UVW[0]], decimal=5) def test_uvw_both_array(): - u, v, w = TARGET.uvw([ANT1, ANT2], [TS, TS], ANT1) - np.testing.assert_array_almost_equal(u, np.array([[0, UVW[0]]] * 2), decimal=5) - np.testing.assert_array_almost_equal(v, np.array([[0, UVW[1]]] * 2), decimal=5) - np.testing.assert_array_almost_equal(w, np.array([[0, UVW[2]]] * 2), decimal=5) + u, v, w = DELAY_TARGET.uvw([ANT1, ANT2], DELAY_TS, ANT1) + # UVW array has shape (3, n_times, n_bls) - stack times along dim 1 and ants along dim 2 + desired_uvw = np.dstack([np.zeros((3, len(DELAY_TS))), np.c_[UVW]]) + np.testing.assert_array_almost_equal([u, v, w], desired_uvw, decimal=5) def test_uvw_hemispheres(): From 18a9f16d66a6e0a188cab6415bd44a57c22d73cf Mon Sep 17 00:00:00 2001 From: Ludwig Schwardt Date: Tue, 11 Aug 2020 15:10:01 +0200 Subject: [PATCH 071/122] Improve PyEphem comparison It looks like the discrepancy was mostly due to the UT1-UTC difference. --- katpoint/test/test_body.py | 1 + 1 file changed, 1 insertion(+) diff --git a/katpoint/test/test_body.py b/katpoint/test/test_body.py index af82859..73988f0 100644 --- a/katpoint/test/test_body.py +++ b/katpoint/test/test_body.py @@ -50,6 +50,7 @@ def _get_earth_satellite(): (_get_fixed_body('10:10:40.123', '40:20:50.567'), '2020-01-01 00:00:00.000', '10:10:40.123', '40:20:50.567', '326:05:57.541', '51:21:20.0119'), # 10:10:40.12 40:20:50.6 326:05:54.8, 51:21:18.5 (PyEphem) + # Adjust time by UT1-UTC=-0.177: 326:05:57.1 51:21:19.9 (PyEphem) (SolarSystemBody('Mars'), '2020-01-01 00:00:00.000', '14:05:58.9201', '-12:13:51.9009', '118:10:05.1129', '27:23:12.8499'), # (PyEphem does GCRS) 118:10:06.1, 27:23:13.3 (PyEphem) From d435375392e960048a47a412da6225d49001d99d Mon Sep 17 00:00:00 2001 From: Ludwig Schwardt Date: Tue, 11 Aug 2020 17:06:22 +0200 Subject: [PATCH 072/122] Check array outputs against corresponding scalars In order to tighten up the tests, compare the elements in the array output of Target methods for 2D timestamps against the corresponding scalar outputs. Introduce a helper function that caters for two flavours: straight numbers that will be assembled into a grand ndarray for comparison, and SkyCoords that contain internal arrays and are better compared via a separation() call (since the basic element is already a 2D spherical direction). Check the numerical value of Target.separation because it is easy. This does add about 7 seconds to the tests on my laptop because of the overhead of scalar Astropy transforms. --- katpoint/test/test_target.py | 38 ++++++++++++++++++++++++++++-------- 1 file changed, 30 insertions(+), 8 deletions(-) diff --git a/katpoint/test/test_target.py b/katpoint/test/test_target.py index e7fd876..9eb775d 100644 --- a/katpoint/test/test_target.py +++ b/katpoint/test/test_target.py @@ -211,30 +211,52 @@ def test_coord_methods_without_antenna(description, methods, raises, error): TS = katpoint.Timestamp('2013-08-14 09:25') +def _array_vs_scalar(func, array_in, sky_coord=False): + """Check that `func` output for 2D array of inputs is array of corresponding scalar outputs.""" + assert array_in.ndim == 2 + array_out = func(array_in) + for i in range(array_in.shape[0]): + for j in range(array_in.shape[1]): + scalar = func(array_in[i, j]) + if sky_coord: + # Treat output as if it is SkyCoord with internal array, check separation instead + assert array_out[i, j].separation(scalar).rad == pytest.approx(0.0) + else: + # Assume that function outputs ndarrays of numbers (or equivalent) + np.testing.assert_array_equal(np.array(array_out)[..., i, j], scalar) + return array_out + + # XXX TLE_TARGET does not support array timestamps yet @pytest.mark.parametrize("description", ['azel, 10, -10', 'radec, 20, -20', 'gal, 30, -30', 'Sun, special']) def test_array_valued_methods(description): """Test array-valued methods (at least their output shapes).""" - offsets = np.array([[0, 1, 2, 3]]) - times = katpoint.Timestamp('2020-07-30 14:02:00') + offsets - assert times.time.shape == offsets.shape + offsets = np.array([[0, 1, 2, 3], [4, 5, 6, 7]]) + times = (katpoint.Timestamp('2020-07-30 14:02:00') + offsets).time + assert times.shape == offsets.shape target = katpoint.Target(description) - assert target.azel(times, ANT1).shape == offsets.shape + azel = _array_vs_scalar(lambda t: target.azel(t, ANT1), times, sky_coord=True) + assert azel.shape == offsets.shape assert target.apparent_radec(times, ANT1).shape == offsets.shape - radec = target.astrometric_radec(times, ANT1) + radec = _array_vs_scalar(lambda t: target.astrometric_radec(t, ANT1), times, sky_coord=True) assert radec.shape == offsets.shape assert target.galactic(times, ANT1).shape == offsets.shape assert target.parallactic_angle(times, ANT1).shape == offsets.shape - delay, delay_rate = target.geometric_delay(ANT2, times, ANT1) + delay, delay_rate = _array_vs_scalar(lambda t: target.geometric_delay(ANT2, t, ANT1), times) assert delay.shape == offsets.shape assert delay_rate.shape == offsets.shape - assert target.uvw_basis(times, ANT1).shape == (3, 3) + offsets.shape + uvw_basis = _array_vs_scalar(lambda t: target.uvw_basis(t, ANT1), times) + assert uvw_basis.shape == (3, 3) + offsets.shape u, v, w = target.uvw([ANT1, ANT2], times, ANT1) assert u.shape == v.shape == w.shape == offsets.shape + (2,) + # Check scalar radec vs array timestamps + _array_vs_scalar(lambda t: target.lmn(radec.ra.rad.flat[0], + radec.dec.rad.flat[0], t, ANT1), times) l, m, n = target.lmn(radec.ra.rad, radec.dec.rad, times, ANT1) assert l.shape == m.shape == n.shape == offsets.shape - assert target.separation(target, times, ANT1).shape == offsets.shape + np.testing.assert_allclose(target.separation(target, times, ANT1).rad, + np.zeros_like(offsets), atol=1e-12) def test_coords(): From d602368ab7133b9a24e821632b5ff88775889bde Mon Sep 17 00:00:00 2001 From: Ludwig Schwardt Date: Tue, 11 Aug 2020 17:18:45 +0200 Subject: [PATCH 073/122] Boost test precision where feasible --- katpoint/test/test_target.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/katpoint/test/test_target.py b/katpoint/test/test_target.py index 9eb775d..3b2b10a 100644 --- a/katpoint/test/test_target.py +++ b/katpoint/test/test_target.py @@ -304,9 +304,9 @@ def test_delay(): def test_uvw(): """Test uvw calculation.""" u, v, w = DELAY_TARGET.uvw(ANT2, DELAY_TS[0], ANT1) - np.testing.assert_almost_equal([u, v, w], UVW[0], decimal=5) + np.testing.assert_almost_equal([u, v, w], UVW[0], decimal=12) u, v, w = DELAY_TARGET.uvw(ANT2, DELAY_TS, ANT1) - np.testing.assert_array_almost_equal([u, v, w], np.c_[UVW], decimal=5) + np.testing.assert_array_almost_equal([u, v, w], np.c_[UVW], decimal=12) def test_uvw_timestamp_array_azel(): @@ -314,19 +314,19 @@ def test_uvw_timestamp_array_azel(): azel = DELAY_TARGET.azel(DELAY_TS[0], ANT1) target = katpoint.construct_azel_target(azel.az, azel.alt) u, v, w = target.uvw(ANT2, DELAY_TS, ANT1) - np.testing.assert_array_almost_equal([u, v, w], np.c_[(UVW[0],) * len(DELAY_TS)], decimal=4) + np.testing.assert_array_almost_equal([u, v, w], np.c_[(UVW[0],) * len(DELAY_TS)], decimal=5) def test_uvw_antenna_array(): u, v, w = DELAY_TARGET.uvw([ANT1, ANT2], DELAY_TS[0], ANT1) - np.testing.assert_array_almost_equal([u, v, w], np.c_[np.zeros(3), UVW[0]], decimal=5) + np.testing.assert_array_almost_equal([u, v, w], np.c_[np.zeros(3), UVW[0]], decimal=12) def test_uvw_both_array(): u, v, w = DELAY_TARGET.uvw([ANT1, ANT2], DELAY_TS, ANT1) # UVW array has shape (3, n_times, n_bls) - stack times along dim 1 and ants along dim 2 desired_uvw = np.dstack([np.zeros((3, len(DELAY_TS))), np.c_[UVW]]) - np.testing.assert_array_almost_equal([u, v, w], desired_uvw, decimal=5) + np.testing.assert_array_almost_equal([u, v, w], desired_uvw, decimal=12) def test_uvw_hemispheres(): @@ -376,7 +376,7 @@ def test_separation(): azel2 = katpoint.construct_azel_target(azel_sun.az, azel_sun.alt + Angle(0.01, unit=u.rad)) sep = azel.separation(azel2, TS, ANT1) - np.testing.assert_almost_equal(sep.rad, 0.01, decimal=7) + np.testing.assert_almost_equal(sep.rad, 0.01, decimal=12) def test_projection(): From 1064a850c8c61312476245ab056b6a7ed34f0ec1 Mon Sep 17 00:00:00 2001 From: Ludwig Schwardt Date: Tue, 11 Aug 2020 17:49:11 +0200 Subject: [PATCH 074/122] Specify that Body takes Frame/SkyCoord instances In order to simplify the interface, specify that both Body.coord and the frame passed to Body.compute can be an instance of either `SkyCoord` or `BaseCoordinateFrame`. This allows us to assume the presence of various methods, since those two classes share a large part of their API. The output of Body.compute is one of these two classes as well, which makes everything consistent. The only code change is that we now instantiate ICRS() and Galactic() instead of passing the classes themselves. This might be very slightly slower. --- katpoint/body.py | 8 ++++---- katpoint/target.py | 8 ++++---- katpoint/test/test_body.py | 2 +- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/katpoint/body.py b/katpoint/body.py index 19d8379..d6f4af0 100644 --- a/katpoint/body.py +++ b/katpoint/body.py @@ -68,8 +68,8 @@ def compute(self, frame, obstime, location): Parameters ---------- - frame : str, :class:`~astropy.coordinates.BaseCoordinateFrame` class or - instance, or :class:`~astropy.coordinates.SkyCoord` instance + frame : :class:`~astropy.coordinates.BaseCoordinateFrame` or + :class:`~astropy.coordinates.SkyCoord` The frame to transform this body's coordinates into obstime : :class:`~astropy.time.Time` The time of observation @@ -106,8 +106,8 @@ def compute(self, frame, obstime=None, location=None): Parameters ---------- - frame : str, :class:`~astropy.coordinates.BaseCoordinateFrame` class or - instance, or :class:`~astropy.coordinates.SkyCoord` instance + frame : :class:`~astropy.coordinates.BaseCoordinateFrame` or + :class:`~astropy.coordinates.SkyCoord` The frame to transform this body's coordinate into obstime : :class:`~astropy.time.Time`, optional The time of observation diff --git a/katpoint/target.py b/katpoint/target.py index ff8c87c..02a2e94 100644 --- a/katpoint/target.py +++ b/katpoint/target.py @@ -146,7 +146,7 @@ def __str__(self): descr += ', %s %s' % (self.body.coord.az.to_string(unit=u.deg), self.body.coord.alt.to_string(unit=u.deg)) if self.body_type == 'gal': - gal = self.body.compute(Galactic) + gal = self.body.compute(Galactic()) descr += ', %.4f %.4f' % (gal.l.deg, gal.b.deg) if self.flux_model is None: descr += ', no flux info' @@ -221,7 +221,7 @@ def description(self): # Check if it's an unnamed target with a default name if names.startswith('Galactic l:'): fields = [tags] - gal = self.body.compute(Galactic) + gal = self.body.compute(Galactic()) fields += ['%.4f' % (gal.l.deg,), '%.4f' % (gal.b.deg,)] if fluxinfo: fields += [fluxinfo] @@ -394,7 +394,7 @@ def astrometric_radec(self, timestamp=None, antenna=None): """ time = Timestamp(timestamp).time _, location = self._normalise_antenna(antenna) - return self.body.compute(ICRS, obstime=time, location=location) + return self.body.compute(ICRS(), obstime=time, location=location) # The default (ra, dec) coordinates are the astrometric ones radec = astrometric_radec @@ -425,7 +425,7 @@ def galactic(self, timestamp=None, antenna=None): """ time = Timestamp(timestamp).time _, location = self._normalise_antenna(antenna) - return self.body.compute(Galactic, obstime=time, location=location) + return self.body.compute(Galactic(), obstime=time, location=location) def parallactic_angle(self, timestamp=None, antenna=None): """Calculate parallactic angle on target as seen from antenna at time(s). diff --git a/katpoint/test/test_body.py b/katpoint/test/test_body.py index 73988f0..a2c6b45 100644 --- a/katpoint/test/test_body.py +++ b/katpoint/test/test_body.py @@ -72,7 +72,7 @@ def test_compute(body, date_str, ra_str, dec_str, az_str, el_str): lon = Longitude('80:00:00.000', unit=u.deg) height = 4200.0 if isinstance(body, EarthSatelliteBody) else 0.0 location = EarthLocation(lat=lat, lon=lon, height=height) - radec = body.compute(ICRS, obstime, location) + radec = body.compute(ICRS(), obstime, location) assert radec.ra.to_string(sep=':', unit=u.hour) == ra_str assert radec.dec.to_string(sep=':') == dec_str altaz = body.compute(AltAz(obstime=obstime, location=location), obstime, location) From 6d7011c5d04985c5fc51125341ddce44373ec34e Mon Sep 17 00:00:00 2001 From: Ludwig Schwardt Date: Tue, 18 Aug 2020 12:39:55 +0200 Subject: [PATCH 075/122] MR fixes Do all array_vs_scalar assertions inside the helper function, including the shape check. This required knowledge of the location of time axes within the output array shape. While we are at it, also generalise the function to handle N-dimensional inputs because it actually simplifies the code (go, ndindex!). Make the test times 3-D (why not?). Extend array_vs_scalar coverage to all standard methods. Fix (az, el) UVW checks. The w value remains the same for the same (az, el) but (u, v) rotates ever so slightly over time. I'm guessing it's the dynamic difference between ICRS and ITRS North Poles that is biting us. --- katpoint/test/test_target.py | 63 +++++++++++++++++------------------- 1 file changed, 29 insertions(+), 34 deletions(-) diff --git a/katpoint/test/test_target.py b/katpoint/test/test_target.py index 3b2b10a..8428c52 100644 --- a/katpoint/test/test_target.py +++ b/katpoint/test/test_target.py @@ -211,49 +211,43 @@ def test_coord_methods_without_antenna(description, methods, raises, error): TS = katpoint.Timestamp('2013-08-14 09:25') -def _array_vs_scalar(func, array_in, sky_coord=False): - """Check that `func` output for 2D array of inputs is array of corresponding scalar outputs.""" - assert array_in.ndim == 2 +def _array_vs_scalar(func, array_in, sky_coord=False, pre_shape=(), post_shape=()): + """Check that `func` output for ndarray of inputs is array of corresponding scalar outputs.""" array_out = func(array_in) - for i in range(array_in.shape[0]): - for j in range(array_in.shape[1]): - scalar = func(array_in[i, j]) - if sky_coord: - # Treat output as if it is SkyCoord with internal array, check separation instead - assert array_out[i, j].separation(scalar).rad == pytest.approx(0.0) - else: - # Assume that function outputs ndarrays of numbers (or equivalent) - np.testing.assert_array_equal(np.array(array_out)[..., i, j], scalar) - return array_out + assert np.shape(array_out) == pre_shape + array_in.shape + post_shape + all_pre = len(pre_shape) * (np.s_[:],) + all_post = len(post_shape) * (np.s_[:],) + for index_in in np.ndindex(array_in.shape): + scalar = func(array_in[index_in]) + if sky_coord: + # Treat output as if it is SkyCoord with internal array, check separation instead + assert array_out[index_in].separation(scalar).rad == pytest.approx(0.0) + else: + # Assume that function outputs more complicated ndarrays of numbers (or equivalent) + array_slice = np.asarray(array_out)[all_pre + index_in + all_post] + np.testing.assert_array_equal(array_slice, np.asarray(scalar)) # XXX TLE_TARGET does not support array timestamps yet @pytest.mark.parametrize("description", ['azel, 10, -10', 'radec, 20, -20', 'gal, 30, -30', 'Sun, special']) def test_array_valued_methods(description): - """Test array-valued methods (at least their output shapes).""" - offsets = np.array([[0, 1, 2, 3], [4, 5, 6, 7]]) + """Test array-valued methods, comparing output against corresponding scalar versions.""" + offsets = np.array([[[0, 1, 2, 3], [4, 5, 6, 7]]]) times = (katpoint.Timestamp('2020-07-30 14:02:00') + offsets).time assert times.shape == offsets.shape target = katpoint.Target(description) - azel = _array_vs_scalar(lambda t: target.azel(t, ANT1), times, sky_coord=True) - assert azel.shape == offsets.shape - assert target.apparent_radec(times, ANT1).shape == offsets.shape - radec = _array_vs_scalar(lambda t: target.astrometric_radec(t, ANT1), times, sky_coord=True) - assert radec.shape == offsets.shape - assert target.galactic(times, ANT1).shape == offsets.shape - assert target.parallactic_angle(times, ANT1).shape == offsets.shape - delay, delay_rate = _array_vs_scalar(lambda t: target.geometric_delay(ANT2, t, ANT1), times) - assert delay.shape == offsets.shape - assert delay_rate.shape == offsets.shape - uvw_basis = _array_vs_scalar(lambda t: target.uvw_basis(t, ANT1), times) - assert uvw_basis.shape == (3, 3) + offsets.shape - u, v, w = target.uvw([ANT1, ANT2], times, ANT1) - assert u.shape == v.shape == w.shape == offsets.shape + (2,) - # Check scalar radec vs array timestamps - _array_vs_scalar(lambda t: target.lmn(radec.ra.rad.flat[0], - radec.dec.rad.flat[0], t, ANT1), times) - l, m, n = target.lmn(radec.ra.rad, radec.dec.rad, times, ANT1) + _array_vs_scalar(lambda t: target.azel(t, ANT1), times, sky_coord=True) + _array_vs_scalar(lambda t: target.apparent_radec(t, ANT1), times, sky_coord=True) + _array_vs_scalar(lambda t: target.astrometric_radec(t, ANT1), times, sky_coord=True) + _array_vs_scalar(lambda t: target.galactic(t, ANT1), times, sky_coord=True) + _array_vs_scalar(lambda t: target.parallactic_angle(t, ANT1), times) + _array_vs_scalar(lambda t: target.geometric_delay(ANT2, t, ANT1), times, pre_shape=(2,)) + _array_vs_scalar(lambda t: target.uvw_basis(t, ANT1), times, pre_shape=(3, 3)) + _array_vs_scalar(lambda t: target.uvw([ANT1, ANT2], t, ANT1), + times, pre_shape=(3,), post_shape=(2,)) + _array_vs_scalar(lambda t: target.lmn(0.0, 0.0, t, ANT1), times, pre_shape=(3,)) + l, m, n = target.lmn(np.zeros_like(offsets), np.zeros_like(offsets), times, ANT1) assert l.shape == m.shape == n.shape == offsets.shape np.testing.assert_allclose(target.separation(target, times, ANT1).rad, np.zeros_like(offsets), atol=1e-12) @@ -314,7 +308,8 @@ def test_uvw_timestamp_array_azel(): azel = DELAY_TARGET.azel(DELAY_TS[0], ANT1) target = katpoint.construct_azel_target(azel.az, azel.alt) u, v, w = target.uvw(ANT2, DELAY_TS, ANT1) - np.testing.assert_array_almost_equal([u, v, w], np.c_[(UVW[0],) * len(DELAY_TS)], decimal=5) + np.testing.assert_array_almost_equal([u[0], v[0], w[0]], UVW[0], decimal=8) + np.testing.assert_array_almost_equal(w, [UVW[0][2]] * len(DELAY_TS), decimal=8) def test_uvw_antenna_array(): From be21b3fcb2a68b9175dfa793001f560564bb09c3 Mon Sep 17 00:00:00 2001 From: Ludwig Schwardt Date: Fri, 7 Aug 2020 16:01:11 +0200 Subject: [PATCH 076/122] Relax checks in anticipation of new Astropy We will need the latest Astropy for TEME support, which also improves the Solar System get_body() routine to include the observer in the calculation of light travel time. This made a 0.3 arcsecond difference to the position of the Moon. The coordinate checks are there to verify the katpoint Bodies and not really to debug Astropy itself, so some tolerance is fine. Relax the coordinate checks to 1 mas precision in general, and 1 arcsec for the Moon. This will be tightened again once we depend on Astropy 4.1+. --- katpoint/test/test_body.py | 41 +++++++++++++++++++++----------------- 1 file changed, 23 insertions(+), 18 deletions(-) diff --git a/katpoint/test/test_body.py b/katpoint/test/test_body.py index a2c6b45..559253b 100644 --- a/katpoint/test/test_body.py +++ b/katpoint/test/test_body.py @@ -25,7 +25,8 @@ import numpy as np from numpy.testing import assert_allclose import astropy.units as u -from astropy.coordinates import SkyCoord, ICRS, AltAz, EarthLocation, Latitude, Longitude +from astropy.coordinates import SkyCoord, ICRS, AltAz, UnitSphericalRepresentation +from astropy.coordinates import EarthLocation, Latitude, Longitude from astropy.time import Time from katpoint.body import FixedBody, SolarSystemBody, EarthSatelliteBody, readtle @@ -44,28 +45,34 @@ def _get_earth_satellite(): return readtle(name, line1, line2) +def _check_separation(actual, lon, lat, tol): + """Check that actual and desired directions are within tolerance.""" + desired = actual.realize_frame(UnitSphericalRepresentation(lon, lat)) + assert actual.separation(desired) <= tol + + @pytest.mark.parametrize( - "body, date_str, ra_str, dec_str, az_str, el_str", + "body, date_str, ra_str, dec_str, az_str, el_str, tol", [ (_get_fixed_body('10:10:40.123', '40:20:50.567'), '2020-01-01 00:00:00.000', - '10:10:40.123', '40:20:50.567', '326:05:57.541', '51:21:20.0119'), - # 10:10:40.12 40:20:50.6 326:05:54.8, 51:21:18.5 (PyEphem) - # Adjust time by UT1-UTC=-0.177: 326:05:57.1 51:21:19.9 (PyEphem) + '10:10:40.123h', '40:20:50.567d', '326:05:57.541d', '51:21:20.0119d', 1 * u.mas), + # 10:10:40.12h 40:20:50.6d 326:05:54.8d 51:21:18.5d (PyEphem) + # Adjust time by UT1-UTC=-0.177: 326:05:57.1d 51:21:19.9 (PyEphem) (SolarSystemBody('Mars'), '2020-01-01 00:00:00.000', - '14:05:58.9201', '-12:13:51.9009', '118:10:05.1129', '27:23:12.8499'), - # (PyEphem does GCRS) 118:10:06.1, 27:23:13.3 (PyEphem) + '14:05:58.9201h', '-12:13:51.9009d', '118:10:05.1129d', '27:23:12.8499d', 1 * u.mas), + # (PyEphem radec is geocentric) 118:10:06.1d 27:23:13.3d (PyEphem) (SolarSystemBody('Moon'), '2020-01-01 10:00:00.000', - '6:44:11.9332', '23:02:08.402', '127:15:17.1381', '60:05:10.2438'), - # (PyEphem does GCRS) 127:15:23.6, 60:05:13.7 (PyEphem) + '6:44:11.9332h', '23:02:08.402d', '127:15:17.1381d', '60:05:10.2438d', 1 * u.arcsec), + # (PyEphem radec is geocentric) 127:15:23.6d 60:05:13.7d (PyEphem) (SolarSystemBody('Sun'), '2020-01-01 10:00:00.000', - '7:56:36.7964', '20:53:59.4553', '234:53:19.4762', '31:38:11.4248'), - # (PyEphem does GCRS) 234:53:20.8, 31:38:09.4 (PyEphem) + '7:56:36.7964h', '20:53:59.4553d', '234:53:19.4762d', '31:38:11.4248d', 1 * u.mas), + # (PyEphem radec is geocentric) 234:53:20.8d 31:38:09.4d (PyEphem) (_get_earth_satellite(), '2019-09-23 07:45:36.000', - '3:32:56.7813', '-2:04:35.4329', '280:32:29.675', '-54:06:50.7456'), - # 3:32:59.21 -2:04:36.3 280:32:07.2 -54:06:14.4 (PyEphem) + '3:32:56.7813h', '-2:04:35.4329d', '280:32:29.675d', '-54:06:50.7456d', 1 * u.mas), + # 3:32:59.21h -2:04:36.3d 280:32:07.2d -54:06:14.4d (PyEphem) ] ) -def test_compute(body, date_str, ra_str, dec_str, az_str, el_str): +def test_compute(body, date_str, ra_str, dec_str, az_str, el_str, tol): """Test compute method""" obstime = Time(date_str) lat = Latitude('10:00:00.000', unit=u.deg) @@ -73,11 +80,9 @@ def test_compute(body, date_str, ra_str, dec_str, az_str, el_str): height = 4200.0 if isinstance(body, EarthSatelliteBody) else 0.0 location = EarthLocation(lat=lat, lon=lon, height=height) radec = body.compute(ICRS(), obstime, location) - assert radec.ra.to_string(sep=':', unit=u.hour) == ra_str - assert radec.dec.to_string(sep=':') == dec_str + _check_separation(radec, ra_str, dec_str, tol) altaz = body.compute(AltAz(obstime=obstime, location=location), obstime, location) - assert altaz.az.to_string(sep=':') == az_str - assert altaz.alt.to_string(sep=':') == el_str + _check_separation(altaz, az_str, el_str, tol) def test_earth_satellite(): From 324b447fb0716308e3419332ef29731655cf0db4 Mon Sep 17 00:00:00 2001 From: Ludwig Schwardt Date: Wed, 12 Aug 2020 16:06:39 +0200 Subject: [PATCH 077/122] Switch to Astropy 4.1rc1 for access to TEME frame This is the first PyPI release that offers the TEME frame as a builtin option. It also introduced some changes to the Solar System ephemerides (topocentric light travel times) and the precision of coordinate tests had to be adjusted. Also include a workaround to allow separation() to be called on the AltAz of a FixedBody vs the AltAz of a SolarSystemBody. The latter contains additional frame attributes (obsgeoloc and obsgeovel) that don't contribute to the calculation but crashes an initial frame equivalency check. --- katpoint/target.py | 11 ++++++++++- katpoint/test/test_target.py | 28 ++++++++++++++-------------- setup.py | 4 ++-- 3 files changed, 26 insertions(+), 17 deletions(-) diff --git a/katpoint/target.py b/katpoint/target.py index 02a2e94..c5a059f 100644 --- a/katpoint/target.py +++ b/katpoint/target.py @@ -777,7 +777,16 @@ def separation(self, other_target, timestamp=None, antenna=None): # Get a common timestamp and antenna for both targets time = Timestamp(timestamp).time antenna, _ = self._normalise_antenna(antenna) - return self.azel(time, antenna).separation(other_target.azel(time, antenna)) + this_azel = self.azel(time, antenna) + other_azel = other_target.azel(time, antenna) + # XXX Work around Astropy 4.1rc1 limitation: + # SkyCoord.separation triggers a frame equivalency check and this crashes + # if one azel has an attribute that the other lacks. The workaround blanks + # out attributes introduced by SolarSystemBody's GCRS coordinates, which + # should not affect separation calculation. + this_azel.obsgeoloc = other_azel.obsgeoloc = None + this_azel.obsgeovel = other_azel.obsgeovel = None + return this_azel.separation(other_azel) def sphere_to_plane(self, az, el, timestamp=None, antenna=None, projection_type='ARC', coord_system='azel'): """Project spherical coordinates to plane with target position as reference. diff --git a/katpoint/test/test_target.py b/katpoint/test/test_target.py index 8428c52..ad64a33 100644 --- a/katpoint/test/test_target.py +++ b/katpoint/test/test_target.py @@ -259,20 +259,20 @@ def test_coords(): assert coord.az.deg == 45 # PyEphem: 45 assert coord.alt.deg == 75 # PyEphem: 75 coord = TARGET.apparent_radec(TS, ANT1) - ra_hour = coord.ra.to_string(unit='hour', sep=':', precision=8) + ra_hour = coord.ra.to_string(unit='hour', sep=':', precision=5) dec_deg = coord.dec.to_string(sep=':', precision=8) - assert ra_hour == '8:53:03.49166920' # PyEphem: 8:53:09.60 (same as astrometric) + assert ra_hour == '8:53:03.49166' # PyEphem: 8:53:09.60 (same as astrometric) assert dec_deg == '-19:54:51.92328722' # PyEphem: -19:51:43.0 (same as astrometric) coord = TARGET.astrometric_radec(TS, ANT1) - ra_hour = coord.ra.to_string(unit='hour', sep=':', precision=8) - dec_deg = coord.dec.to_string(sep=':', precision=8) - assert ra_hour == '8:53:09.60397465' # PyEphem: 8:53:09.60 - assert dec_deg == '-19:51:42.87773802' # PyEphem: -19:51:43.0 + ra_hour = coord.ra.to_string(unit='hour', sep=':', precision=5) + dec_deg = coord.dec.to_string(sep=':', precision=6) + assert ra_hour == '8:53:09.60397' # PyEphem: 8:53:09.60 + assert dec_deg == '-19:51:42.877738' # PyEphem: -19:51:43.0 coord = TARGET.galactic(TS, ANT1) - l_deg = coord.l.to_string(sep=':', precision=8) - b_deg = coord.b.to_string(sep=':', precision=8) - assert l_deg == '245:34:49.20442837' # PyEphem: 245:34:49.3 - assert b_deg == '15:36:24.87974969' # PyEphem: 15:36:24.7 + l_deg = coord.l.to_string(sep=':', precision=4) + b_deg = coord.b.to_string(sep=':', precision=4) + assert l_deg == '245:34:49.2044' # PyEphem: 245:34:49.3 + assert b_deg == '15:36:24.8797' # PyEphem: 15:36:24.7 coord = TARGET.parallactic_angle(TS, ANT1) assert coord.deg == pytest.approx(-140.279593566336) # PyEphem: -140.34440985011398 @@ -298,9 +298,9 @@ def test_delay(): def test_uvw(): """Test uvw calculation.""" u, v, w = DELAY_TARGET.uvw(ANT2, DELAY_TS[0], ANT1) - np.testing.assert_almost_equal([u, v, w], UVW[0], decimal=12) + np.testing.assert_almost_equal([u, v, w], UVW[0], decimal=8) u, v, w = DELAY_TARGET.uvw(ANT2, DELAY_TS, ANT1) - np.testing.assert_array_almost_equal([u, v, w], np.c_[UVW], decimal=12) + np.testing.assert_array_almost_equal([u, v, w], np.c_[UVW], decimal=8) def test_uvw_timestamp_array_azel(): @@ -314,14 +314,14 @@ def test_uvw_timestamp_array_azel(): def test_uvw_antenna_array(): u, v, w = DELAY_TARGET.uvw([ANT1, ANT2], DELAY_TS[0], ANT1) - np.testing.assert_array_almost_equal([u, v, w], np.c_[np.zeros(3), UVW[0]], decimal=12) + np.testing.assert_array_almost_equal([u, v, w], np.c_[np.zeros(3), UVW[0]], decimal=8) def test_uvw_both_array(): u, v, w = DELAY_TARGET.uvw([ANT1, ANT2], DELAY_TS, ANT1) # UVW array has shape (3, n_times, n_bls) - stack times along dim 1 and ants along dim 2 desired_uvw = np.dstack([np.zeros((3, len(DELAY_TS))), np.c_[UVW]]) - np.testing.assert_array_almost_equal([u, v, w], desired_uvw, decimal=12) + np.testing.assert_array_almost_equal([u, v, w], desired_uvw, decimal=8) def test_uvw_hemispheres(): diff --git a/setup.py b/setup.py index c492a29..55dcc94 100755 --- a/setup.py +++ b/setup.py @@ -49,11 +49,11 @@ platforms=["OS Independent"], keywords="meerkat ska", zip_safe=False, - python_requires='>=3.5, <4', + python_requires='>=3.6, <4', setup_requires=['katversion'], use_katversion=True, install_requires=[ - "astropy", + "astropy>=4.1rc1", "numpy", "pyorbital", "sgp4", From 26cb0440c08e6c3ff8057a0b38ec3eec998a4b30 Mon Sep 17 00:00:00 2001 From: Ludwig Schwardt Date: Wed, 12 Aug 2020 17:55:44 +0200 Subject: [PATCH 078/122] Use latest Moon position for increased accuracy Restore the precision of the test back to 1 milliarcsecond by using the latest GCRS light travel time calculations for the Moon in 4.1rc1. --- katpoint/test/test_body.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/katpoint/test/test_body.py b/katpoint/test/test_body.py index 559253b..fa5a4f2 100644 --- a/katpoint/test/test_body.py +++ b/katpoint/test/test_body.py @@ -62,7 +62,7 @@ def _check_separation(actual, lon, lat, tol): '14:05:58.9201h', '-12:13:51.9009d', '118:10:05.1129d', '27:23:12.8499d', 1 * u.mas), # (PyEphem radec is geocentric) 118:10:06.1d 27:23:13.3d (PyEphem) (SolarSystemBody('Moon'), '2020-01-01 10:00:00.000', - '6:44:11.9332h', '23:02:08.402d', '127:15:17.1381d', '60:05:10.2438d', 1 * u.arcsec), + '6:44:11.9332h', '23:02:08.402d', '127:15:17.1418d', '60:05:10.5475d', 1 * u.mas), # (PyEphem radec is geocentric) 127:15:23.6d 60:05:13.7d (PyEphem) (SolarSystemBody('Sun'), '2020-01-01 10:00:00.000', '7:56:36.7964h', '20:53:59.4553d', '234:53:19.4762d', '31:38:11.4248d', 1 * u.mas), From b9cc5756663552289709543ea3123aa14ccb6d8c Mon Sep 17 00:00:00 2001 From: Ludwig Schwardt Date: Tue, 18 Aug 2020 15:56:19 +0200 Subject: [PATCH 079/122] Use Astropy TEME frame instead of Pyorbital Earth satellites described by the SGP4 model (and distributed as Two-Line Elements) return positions and velocities relative to the True Equator, Mean Equinox (TEME) frame, an Earth-centred inertial frame somewhat like GCRS. This used to be handled by Pyorbital but since version 4.1 Astropy will have builtin support for this frame (see astropy/astropy#10149). Since we don't need the (az, el) of an observer in this calculation anymore, it is now safe to call `EarthSatelliteBody.compute` without a location (it defaults to the centre of the Earth). Update the tests to reflect this. Add tests against Skyfield which has decent satellite support. Consolidate the TLE test parameters as module constants in the process. The new approach is within 0.25 arcseconds of Skyfield, instead of 1.7 arcseconds with the Pyorbital approach. This addresses JIRA ticket SPAZA-120. --- katpoint/body.py | 85 +++--------------------------------- katpoint/test/test_body.py | 55 +++++++++++++++++------ katpoint/test/test_target.py | 3 +- 3 files changed, 51 insertions(+), 92 deletions(-) diff --git a/katpoint/body.py b/katpoint/body.py index d6f4af0..990bb61 100644 --- a/katpoint/body.py +++ b/katpoint/body.py @@ -24,14 +24,12 @@ from astropy.time import Time, TimeDelta from astropy.coordinates import ICRS, SkyCoord, AltAz from astropy.coordinates import solar_system_ephemeris, get_body +from astropy.coordinates import TEME, CartesianDifferential, CartesianRepresentation import sgp4.model import sgp4.earth_gravity from sgp4.propagation import sgp4init -import pyorbital.geoloc -import pyorbital.astronomy - from .ephem_extra import angle_from_degrees @@ -174,11 +172,9 @@ class EarthSatelliteBody(Body): def __init__(self, name): super().__init__(name) - def compute(self, frame, obstime, location): + def compute(self, frame, obstime, location=None): """Determine position of body at the given time and transform to `frame`.""" - if location is None: - raise ValueError('EarthSatelliteBody needs a location to calculate coordinates - ' - 'did you specify an Antenna?') + Body._check_location(frame) # Create an SGP4 satellite object self._sat = sgp4.model.Satellite() self._sat.whichconst = sgp4.earth_gravity.wgs84 @@ -222,18 +218,10 @@ def compute(self, frame, obstime, location): self._sat.nodeo, self._sat) p, v = self._sat.propagate(yr, mon, day, h, m, s) - # Convert to lon/lat/alt - utc_time = datetime.datetime(yr, mon, day, h, m, int(s), int(s - int(s)) * 1000000) - pos = np.array(p) - lon, lat, alt = pyorbital.geoloc.get_lonlatalt(pos, utc_time) - - # Convert to alt, az at observer - az, alt = get_observer_look(lon, lat, alt, utc_time, - location.lon.deg, location.lat.deg, - location.height.to(u.kilometer).value) - - altaz = SkyCoord(az*u.deg, alt*u.deg, frame=AltAz, obstime=obstime, location=location) - return altaz.transform_to(frame) + teme_p = CartesianRepresentation(p * u.km) + teme_v = CartesianDifferential(v * u.km / u.s) + teme = TEME(teme_p.with_differentials(teme_v), obstime=obstime) + return teme.transform_to(frame) def writedb(self): """ Create an XEphem catalogue entry. @@ -346,65 +334,6 @@ def readtle(name, line1, line2): return s -def get_observer_look(sat_lon, sat_lat, sat_alt, utc_time, lon, lat, alt): - """Calculate observers look angle to a satellite. - - http://celestrak.com/columns/v02n02/ - - Parameters - ---------- - utc_time: datetime.datetime - Observation time - - lon: float - Longitude of observer position on ground in degrees east - - lat: float - Latitude of observer position on ground in degrees north - - alt: float - Altitude above sea-level (geoid) of observer position on ground in km - - Return: (Azimuth, Elevation) - """ - (pos_x, pos_y, pos_z), (vel_x, vel_y, vel_z) = \ - pyorbital.astronomy.observer_position( - utc_time, sat_lon, sat_lat, sat_alt) - - (opos_x, opos_y, opos_z), (ovel_x, ovel_y, ovel_z) = \ - pyorbital.astronomy.observer_position(utc_time, lon, lat, alt) - - lon = np.deg2rad(lon) - lat = np.deg2rad(lat) - - theta = (pyorbital.astronomy.gmst(utc_time) + lon) % (2 * np.pi) - - rx = pos_x - opos_x - ry = pos_y - opos_y - rz = pos_z - opos_z - - sin_lat = np.sin(lat) - cos_lat = np.cos(lat) - sin_theta = np.sin(theta) - cos_theta = np.cos(theta) - - top_s = sin_lat * cos_theta * rx + \ - sin_lat * sin_theta * ry - cos_lat * rz - top_e = -sin_theta * rx + cos_theta * ry - top_z = cos_lat * cos_theta * rx + \ - cos_lat * sin_theta * ry + sin_lat * rz - - az_ = np.arctan(-top_e / top_s) - - az_ = np.where(top_s > 0, az_ + np.pi, az_) - az_ = np.where(az_ < 0, az_ + 2 * np.pi, az_) - - rg_ = np.sqrt(rx * rx + ry * ry + rz * rz) - el_ = np.arcsin(top_z / rg_) - - return np.rad2deg(az_), np.rad2deg(el_) - - class StationaryBody(Body): """Stationary body with fixed (az, el) coordinates. diff --git a/katpoint/test/test_body.py b/katpoint/test/test_body.py index fa5a4f2..9cbdb8b 100644 --- a/katpoint/test/test_body.py +++ b/katpoint/test/test_body.py @@ -31,6 +31,13 @@ from katpoint.body import FixedBody, SolarSystemBody, EarthSatelliteBody, readtle +try: + from skyfield.api import load, EarthSatellite, Topos +except ImportError: + HAS_SKYFIELD = False +else: + HAS_SKYFIELD = True + def _get_fixed_body(ra_str, dec_str): ra = Longitude(ra_str, unit=u.hour) @@ -38,11 +45,20 @@ def _get_fixed_body(ra_str, dec_str): return FixedBody('name', SkyCoord(ra=ra, dec=dec, frame=ICRS)) -def _get_earth_satellite(): - name = ' GPS BIIA-21 (PRN 09) ' - line1 = '1 22700U 93042A 19266.32333151 .00000012 00000-0 10000-3 0 8057' - line2 = '2 22700 55.4408 61.3790 0191986 78.1802 283.9935 2.00561720104282' - return readtle(name, line1, line2) +TLE_NAME = ' GPS BIIA-21 (PRN 09) ' +TLE_LINE1 = '1 22700U 93042A 19266.32333151 .00000012 00000-0 10000-3 0 8057' +TLE_LINE2 = '2 22700 55.4408 61.3790 0191986 78.1802 283.9935 2.00561720104282' +TLE_TS = '2019-09-23 07:45:36.000' +TLE_AZ = '280:32:28.4266d' +# 1. 280:32:28.6053 Skyfield (0.23" error) +# 2. 280:32:29.675 Astropy 4.0.1 + PyOrbital for TEME (1.67" error) +# 3. 280:32:07.2d PyEphem (37" error) +TLE_EL = '-54:06:49.2409d' +# 1. -54:06:49.0358 Skyfield +# 2. -54:06:50.7456 Astropy 4.0.1 + PyOrbital for TEME +# 3. -54:06:14.4 PyEphem +TLE_LOCATION = EarthLocation(lat=10.0, lon=80.0, height=4200.0) +LOCATION = EarthLocation(lat=10.0, lon=80.0, height=0.0) def _check_separation(actual, lon, lat, tol): @@ -67,26 +83,39 @@ def _check_separation(actual, lon, lat, tol): (SolarSystemBody('Sun'), '2020-01-01 10:00:00.000', '7:56:36.7964h', '20:53:59.4553d', '234:53:19.4762d', '31:38:11.4248d', 1 * u.mas), # (PyEphem radec is geocentric) 234:53:20.8d 31:38:09.4d (PyEphem) - (_get_earth_satellite(), '2019-09-23 07:45:36.000', - '3:32:56.7813h', '-2:04:35.4329d', '280:32:29.675d', '-54:06:50.7456d', 1 * u.mas), - # 3:32:59.21h -2:04:36.3d 280:32:07.2d -54:06:14.4d (PyEphem) + (readtle(TLE_NAME, TLE_LINE1, TLE_LINE2), TLE_TS, + '0:00:38.5009h', '00:03:56.0093d', TLE_AZ, TLE_EL, 1 * u.mas), ] ) def test_compute(body, date_str, ra_str, dec_str, az_str, el_str, tol): """Test compute method""" obstime = Time(date_str) - lat = Latitude('10:00:00.000', unit=u.deg) - lon = Longitude('80:00:00.000', unit=u.deg) - height = 4200.0 if isinstance(body, EarthSatelliteBody) else 0.0 - location = EarthLocation(lat=lat, lon=lon, height=height) + location = TLE_LOCATION if isinstance(body, EarthSatelliteBody) else LOCATION radec = body.compute(ICRS(), obstime, location) _check_separation(radec, ra_str, dec_str, tol) altaz = body.compute(AltAz(obstime=obstime, location=location), obstime, location) _check_separation(altaz, az_str, el_str, tol) +@pytest.mark.skipif(not HAS_SKYFIELD, reason="Skyfield is not installed") +def test_earth_satellite_vs_skyfield(): + ts = load.timescale() + satellite = EarthSatellite(TLE_LINE1, TLE_LINE2, TLE_NAME, ts) + antenna = Topos(latitude_degrees=TLE_LOCATION.lat.deg, + longitude_degrees=TLE_LOCATION.lon.deg, + elevation_m=TLE_LOCATION.height.value) + obstime = Time(TLE_TS) + t = ts.from_astropy(obstime) + towards_sat = (satellite - antenna).at(t) + alt, az, distance = towards_sat.altaz() + altaz = AltAz(alt=Latitude(alt.radians, unit=u.rad), + az=Longitude(az.radians, unit=u.rad), + obstime=obstime, location=TLE_LOCATION) + _check_separation(altaz, TLE_AZ, TLE_EL, 0.25 * u.arcsec) + + def test_earth_satellite(): - sat = _get_earth_satellite() + sat = readtle(TLE_NAME, TLE_LINE1, TLE_LINE2) # Check that the EarthSatelliteBody object has the expected attribute values assert str(sat._epoch) == '2019-09-23 07:45:35.842' assert sat._inc == np.deg2rad(55.4408) diff --git a/katpoint/test/test_target.py b/katpoint/test/test_target.py index ad64a33..49efa24 100644 --- a/katpoint/test/test_target.py +++ b/katpoint/test/test_target.py @@ -194,7 +194,8 @@ def does_not_raise(error): ('gal, 30, -30', NON_AZEL, does_not_raise, None), ('Sun, special', 'azel', pytest.raises, ValueError), ('Sun, special', NON_AZEL, does_not_raise, None), - (TLE_TARGET, 'azel ' + NON_AZEL, pytest.raises, ValueError), + (TLE_TARGET, 'azel', pytest.raises, ValueError), + (TLE_TARGET, NON_AZEL, does_not_raise, None), ] ) def test_coord_methods_without_antenna(description, methods, raises, error): From 56eb1d6259ff2adf7762fda851f481a03f1bec0c Mon Sep 17 00:00:00 2001 From: Ludwig Schwardt Date: Tue, 18 Aug 2020 16:11:49 +0200 Subject: [PATCH 080/122] Remove pyorbital and friends from requirements --- gitlab-ci-requirements.txt | 1 - requirements.txt | 3 --- setup.py | 1 - system-requirements.txt | 3 --- 4 files changed, 8 deletions(-) diff --git a/gitlab-ci-requirements.txt b/gitlab-ci-requirements.txt index 32c327d..5a242bc 100644 --- a/gitlab-ci-requirements.txt +++ b/gitlab-ci-requirements.txt @@ -6,7 +6,6 @@ pipdeptree pygments pylint pylint-junit -pyorbital pytest pytest-cov pytest-pylint diff --git a/requirements.txt b/requirements.txt index 7bfcf7d..f04d98b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,3 @@ astropy numpy -pyorbital==1.5.0 -requests # via pyorbital -scipy # via pyorbital sgp4==2.4 diff --git a/setup.py b/setup.py index 55dcc94..11ecf6a 100755 --- a/setup.py +++ b/setup.py @@ -55,7 +55,6 @@ install_requires=[ "astropy>=4.1rc1", "numpy", - "pyorbital", "sgp4", ], tests_require=[ diff --git a/system-requirements.txt b/system-requirements.txt index dbc8429..562a846 100644 --- a/system-requirements.txt +++ b/system-requirements.txt @@ -1,9 +1,6 @@ astropy numpy -pyorbital pytest pytest-cov -requests -scipy sgp4 virtualenv From 2ad8c949e8ec23cff331db5196fde3704d5adb5b Mon Sep 17 00:00:00 2001 From: Ludwig Schwardt Date: Tue, 18 Aug 2020 16:15:34 +0200 Subject: [PATCH 081/122] Bump Astropy requirement to 4.1rc1 This is the first release with the TEME frame. --- gitlab-ci-requirements.txt | 2 +- requirements.txt | 2 +- system-requirements.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/gitlab-ci-requirements.txt b/gitlab-ci-requirements.txt index 5a242bc..29b7a20 100644 --- a/gitlab-ci-requirements.txt +++ b/gitlab-ci-requirements.txt @@ -1,4 +1,4 @@ -astropy +astropy>=4.1rc1 docutils markupsafe numpy diff --git a/requirements.txt b/requirements.txt index f04d98b..20193ce 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,3 @@ -astropy +astropy==4.1rc1 numpy sgp4==2.4 diff --git a/system-requirements.txt b/system-requirements.txt index 562a846..b4c8cd1 100644 --- a/system-requirements.txt +++ b/system-requirements.txt @@ -1,4 +1,4 @@ -astropy +astropy>=4.1rc1 numpy pytest pytest-cov From 3ed6b0ec6980da035a0da2b789d93a2b84387dea Mon Sep 17 00:00:00 2001 From: Ludwig Schwardt Date: Tue, 18 Aug 2020 16:18:51 +0200 Subject: [PATCH 082/122] Add a note about Astropy workaround We should be able to remove this once Astropy 4.1 is released, which includes astropy/astropy#10658. --- katpoint/target.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/katpoint/target.py b/katpoint/target.py index c5a059f..62c0d37 100644 --- a/katpoint/target.py +++ b/katpoint/target.py @@ -779,11 +779,12 @@ def separation(self, other_target, timestamp=None, antenna=None): antenna, _ = self._normalise_antenna(antenna) this_azel = self.azel(time, antenna) other_azel = other_target.azel(time, antenna) - # XXX Work around Astropy 4.1rc1 limitation: + # XXX Work around Astropy 4.1rc1 limitation (should be fixed in 4.1): # SkyCoord.separation triggers a frame equivalency check and this crashes # if one azel has an attribute that the other lacks. The workaround blanks # out attributes introduced by SolarSystemBody's GCRS coordinates, which # should not affect separation calculation. + # This is fixed by astropy/astropy#10658, slated for the 4.1 milestone. this_azel.obsgeoloc = other_azel.obsgeoloc = None this_azel.obsgeovel = other_azel.obsgeovel = None return this_azel.separation(other_azel) From 60bff94e473de902783e6f274fb24ef075aa404d Mon Sep 17 00:00:00 2001 From: Ludwig Schwardt Date: Tue, 18 Aug 2020 22:11:36 +0200 Subject: [PATCH 083/122] Remove unused imports Also prefer "from astropy import units as u" for some reason. --- katpoint/antenna.py | 2 +- katpoint/body.py | 5 ++--- katpoint/ephem_extra.py | 2 +- katpoint/stars.py | 2 +- katpoint/target.py | 2 +- katpoint/test/test_body.py | 4 ++-- katpoint/test/test_conversion.py | 2 +- katpoint/test/test_delay.py | 2 +- katpoint/test/test_target.py | 2 +- katpoint/test/test_timestamp.py | 2 +- 10 files changed, 12 insertions(+), 13 deletions(-) diff --git a/katpoint/antenna.py b/katpoint/antenna.py index 5943474..2a15f39 100644 --- a/katpoint/antenna.py +++ b/katpoint/antenna.py @@ -22,7 +22,7 @@ """ import numpy as np -import astropy.units as u +from astropy import units as u from astropy.coordinates import Latitude, Longitude, EarthLocation from .timestamp import Timestamp diff --git a/katpoint/body.py b/katpoint/body.py index 990bb61..a8354a4 100644 --- a/katpoint/body.py +++ b/katpoint/body.py @@ -17,12 +17,11 @@ """A celestial body that can compute its sky position, inspired by PyEphem.""" import copy -import datetime import numpy as np -import astropy.units as u +from astropy import units as u from astropy.time import Time, TimeDelta -from astropy.coordinates import ICRS, SkyCoord, AltAz +from astropy.coordinates import ICRS, AltAz from astropy.coordinates import solar_system_ephemeris, get_body from astropy.coordinates import TEME, CartesianDifferential, CartesianRepresentation diff --git a/katpoint/ephem_extra.py b/katpoint/ephem_extra.py index 2e79c7d..ab9a905 100644 --- a/katpoint/ephem_extra.py +++ b/katpoint/ephem_extra.py @@ -17,7 +17,7 @@ """Enhancements to PyEphem.""" import numpy as np -import astropy.units as u +from astropy import units as u from astropy.coordinates import Angle # -------------------------------------------------------------------------------------------------- diff --git a/katpoint/stars.py b/katpoint/stars.py index b08ac00..e112039 100644 --- a/katpoint/stars.py +++ b/katpoint/stars.py @@ -28,7 +28,7 @@ """ import numpy as np -import astropy.units as u +from astropy import units as u from astropy.coordinates import SkyCoord, Longitude, Latitude, ICRS from astropy.time import Time diff --git a/katpoint/target.py b/katpoint/target.py index 62c0d37..3216a70 100644 --- a/katpoint/target.py +++ b/katpoint/target.py @@ -17,7 +17,7 @@ """Target object used for pointing and flux density calculation.""" import numpy as np -import astropy.units as u +from astropy import units as u from astropy.coordinates import SkyCoord # High-level coordinates from astropy.coordinates import ICRS, Galactic, FK4, AltAz, CIRS # Low-level frames from astropy.coordinates import Latitude, Longitude, Angle # Angles diff --git a/katpoint/test/test_body.py b/katpoint/test/test_body.py index 9cbdb8b..0cd8536 100644 --- a/katpoint/test/test_body.py +++ b/katpoint/test/test_body.py @@ -24,10 +24,10 @@ import pytest import numpy as np from numpy.testing import assert_allclose -import astropy.units as u +from astropy import units as u +from astropy.time import Time from astropy.coordinates import SkyCoord, ICRS, AltAz, UnitSphericalRepresentation from astropy.coordinates import EarthLocation, Latitude, Longitude -from astropy.time import Time from katpoint.body import FixedBody, SolarSystemBody, EarthSatelliteBody, readtle diff --git a/katpoint/test/test_conversion.py b/katpoint/test/test_conversion.py index 3af364b..b736d98 100644 --- a/katpoint/test/test_conversion.py +++ b/katpoint/test/test_conversion.py @@ -18,7 +18,7 @@ import pytest import numpy as np -import astropy.units as u +from astropy import units as u from astropy.coordinates import Angle import katpoint diff --git a/katpoint/test/test_delay.py b/katpoint/test/test_delay.py index 1beed09..c74a570 100644 --- a/katpoint/test/test_delay.py +++ b/katpoint/test/test_delay.py @@ -21,7 +21,7 @@ import pytest import numpy as np -import astropy.units as u +from astropy import units as u from astropy.coordinates import Angle import katpoint diff --git a/katpoint/test/test_target.py b/katpoint/test/test_target.py index 49efa24..7e36f58 100644 --- a/katpoint/test/test_target.py +++ b/katpoint/test/test_target.py @@ -22,7 +22,7 @@ import numpy as np import pytest -import astropy.units as u +from astropy import units as u from astropy.coordinates import Angle import katpoint diff --git a/katpoint/test/test_timestamp.py b/katpoint/test/test_timestamp.py index 8981f8c..ad35c5a 100644 --- a/katpoint/test/test_timestamp.py +++ b/katpoint/test/test_timestamp.py @@ -21,7 +21,7 @@ import pytest import numpy as np -import astropy.units as u +from astropy import units as u from astropy.time import Time, TimeDelta import katpoint From ffb09d3bd330e46dbcdb702cf0e88ad49da299b2 Mon Sep 17 00:00:00 2001 From: Ludwig Schwardt Date: Fri, 21 Aug 2020 15:40:32 +0200 Subject: [PATCH 084/122] Perform more relaxed coordinate separation tests Instead of squeezing each test to the maximum precision, do a basic separation check with 1 mas precision. This avoids testing Astropy itself, which typically still experiences small changes with each new release. Reuse the helper function in test_body. Dig out the older more precise coordinate targets in case we want to increase our precision. --- katpoint/test/helper.py | 7 +++++++ katpoint/test/test_body.py | 13 +++++-------- katpoint/test/test_target.py | 20 ++++++++------------ 3 files changed, 20 insertions(+), 20 deletions(-) diff --git a/katpoint/test/helper.py b/katpoint/test/helper.py index d5dee2b..55887f4 100644 --- a/katpoint/test/helper.py +++ b/katpoint/test/helper.py @@ -17,9 +17,16 @@ """Shared pytest utilities.""" import numpy as np +from astropy.coordinates import UnitSphericalRepresentation def assert_angles_almost_equal(x, y, **kwargs): def primary_angle(x): return x - np.round(x / (2.0 * np.pi)) * 2.0 * np.pi np.testing.assert_almost_equal(primary_angle(x - y), np.zeros(np.shape(x)), **kwargs) + + +def check_separation(actual, lon, lat, tol): + """Check that actual and desired directions are within tolerance.""" + desired = actual.realize_frame(UnitSphericalRepresentation(lon, lat)) + assert actual.separation(desired) <= tol diff --git a/katpoint/test/test_body.py b/katpoint/test/test_body.py index 0cd8536..61decec 100644 --- a/katpoint/test/test_body.py +++ b/katpoint/test/test_body.py @@ -26,10 +26,11 @@ from numpy.testing import assert_allclose from astropy import units as u from astropy.time import Time -from astropy.coordinates import SkyCoord, ICRS, AltAz, UnitSphericalRepresentation +from astropy.coordinates import SkyCoord, ICRS, AltAz from astropy.coordinates import EarthLocation, Latitude, Longitude from katpoint.body import FixedBody, SolarSystemBody, EarthSatelliteBody, readtle +from katpoint.test.helper import check_separation try: from skyfield.api import load, EarthSatellite, Topos @@ -61,10 +62,6 @@ def _get_fixed_body(ra_str, dec_str): LOCATION = EarthLocation(lat=10.0, lon=80.0, height=0.0) -def _check_separation(actual, lon, lat, tol): - """Check that actual and desired directions are within tolerance.""" - desired = actual.realize_frame(UnitSphericalRepresentation(lon, lat)) - assert actual.separation(desired) <= tol @pytest.mark.parametrize( @@ -92,9 +89,9 @@ def test_compute(body, date_str, ra_str, dec_str, az_str, el_str, tol): obstime = Time(date_str) location = TLE_LOCATION if isinstance(body, EarthSatelliteBody) else LOCATION radec = body.compute(ICRS(), obstime, location) - _check_separation(radec, ra_str, dec_str, tol) + check_separation(radec, ra_str, dec_str, tol) altaz = body.compute(AltAz(obstime=obstime, location=location), obstime, location) - _check_separation(altaz, az_str, el_str, tol) + check_separation(altaz, az_str, el_str, tol) @pytest.mark.skipif(not HAS_SKYFIELD, reason="Skyfield is not installed") @@ -111,7 +108,7 @@ def test_earth_satellite_vs_skyfield(): altaz = AltAz(alt=Latitude(alt.radians, unit=u.rad), az=Longitude(az.radians, unit=u.rad), obstime=obstime, location=TLE_LOCATION) - _check_separation(altaz, TLE_AZ, TLE_EL, 0.25 * u.arcsec) + check_separation(altaz, TLE_AZ, TLE_EL, 0.25 * u.arcsec) def test_earth_satellite(): diff --git a/katpoint/test/test_target.py b/katpoint/test/test_target.py index 7e36f58..48ec999 100644 --- a/katpoint/test/test_target.py +++ b/katpoint/test/test_target.py @@ -26,6 +26,8 @@ from astropy.coordinates import Angle import katpoint +from katpoint.test.helper import check_separation + # Use the current year in TLE epochs to avoid potential crashes due to expired TLEs YY = time.localtime().tm_year % 100 @@ -260,20 +262,14 @@ def test_coords(): assert coord.az.deg == 45 # PyEphem: 45 assert coord.alt.deg == 75 # PyEphem: 75 coord = TARGET.apparent_radec(TS, ANT1) - ra_hour = coord.ra.to_string(unit='hour', sep=':', precision=5) - dec_deg = coord.dec.to_string(sep=':', precision=8) - assert ra_hour == '8:53:03.49166' # PyEphem: 8:53:09.60 (same as astrometric) - assert dec_deg == '-19:54:51.92328722' # PyEphem: -19:51:43.0 (same as astrometric) + check_separation(coord, '8:53:03.49166920h', '-19:54:51.92328722d', tol=1 * u.mas) + # PyEphem: 8:53:09.60, -19:51:43.0 (same as astrometric) coord = TARGET.astrometric_radec(TS, ANT1) - ra_hour = coord.ra.to_string(unit='hour', sep=':', precision=5) - dec_deg = coord.dec.to_string(sep=':', precision=6) - assert ra_hour == '8:53:09.60397' # PyEphem: 8:53:09.60 - assert dec_deg == '-19:51:42.877738' # PyEphem: -19:51:43.0 + check_separation(coord, '8:53:09.60397465h', '-19:51:42.87773802d', tol=1 * u.mas) + # PyEphem: 8:53:09.60, -19:51:43.0 coord = TARGET.galactic(TS, ANT1) - l_deg = coord.l.to_string(sep=':', precision=4) - b_deg = coord.b.to_string(sep=':', precision=4) - assert l_deg == '245:34:49.2044' # PyEphem: 245:34:49.3 - assert b_deg == '15:36:24.8797' # PyEphem: 15:36:24.7 + check_separation(coord, '245:34:49.20442837d', '15:36:24.87974969d', tol=1 * u.mas) + # PyEphem: 245:34:49.3, 15:36:24.7 coord = TARGET.parallactic_angle(TS, ANT1) assert coord.deg == pytest.approx(-140.279593566336) # PyEphem: -140.34440985011398 From df9e971508cb7d24f0c0d075c213afb720d2cc5f Mon Sep 17 00:00:00 2001 From: Ludwig Schwardt Date: Fri, 21 Aug 2020 13:51:47 +0200 Subject: [PATCH 085/122] Use SGP4 TLE routines instead of homegrown one Use Satrec.twoline2rv to construct an SGP4 Satrec object from a TLE, instead of a homegrown one with its maintenance and fragility issues. Construct the EarthSatelliteBody around this object instead of stuffing an empty Body with orbital parameters from the outside. On top of this, use the WGS72 gravity model instead of WGS84, since that is the underlying basis for most TLE derivations. Also use the modern SGP4 2.x interface, which calls the actual C++ code (via sgp4.wrapper.Satrec) instead of emulating it in Python (via sgp4.model.Satrec). Use its array interface to compute many time steps at once if needed. This has implications for reading and writing EDB entries as well. Completely rework this part, with helper functions to get exact correspondence between Astropy Time objects and EDB epochs, by mimicking what libastro does down to its use of single-precision floats. This preserves round-tripping from description string back to description string, at least while we are still using EDB format as the description string. While the description string matches are now exact, the (az, el) comparisons in the tests have suffered a little... Still trying to figure this out. This addresses JIRA ticket SPAZA-121. --- katpoint/body.py | 250 ++++++++++++++----------------------- katpoint/stars.py | 68 ++++++---- katpoint/target.py | 4 +- katpoint/test/test_body.py | 93 +++++--------- 4 files changed, 164 insertions(+), 251 deletions(-) diff --git a/katpoint/body.py b/katpoint/body.py index a8354a4..9c059de 100644 --- a/katpoint/body.py +++ b/katpoint/body.py @@ -16,18 +16,14 @@ """A celestial body that can compute its sky position, inspired by PyEphem.""" -import copy - import numpy as np from astropy import units as u -from astropy.time import Time, TimeDelta +from astropy.time import Time from astropy.coordinates import ICRS, AltAz from astropy.coordinates import solar_system_ephemeris, get_body from astropy.coordinates import TEME, CartesianDifferential, CartesianRepresentation - -import sgp4.model -import sgp4.earth_gravity -from sgp4.propagation import sgp4init +from sgp4.api import Satrec +from sgp4.model import Satrec as SatrecPython from .ephem_extra import angle_from_degrees @@ -159,6 +155,24 @@ def compute(self, frame, obstime, location=None): return gcrs.transform_to(frame) +def _time_to_edb(t, high_precision=False): + """Construct XEphem EDB epoch string from `Time` object.""" + if not high_precision: + # The XEphem startok/endok epochs are also single-precision MJDs + t = Time(np.float32(t.mjd), format='mjd') + dt = t.datetime + second = dt.second + dt.microsecond / 1e6 + minute = dt.minute + second / 60. + hour = dt.hour + minute / 60. + day = dt.day + hour / 24. + if high_precision: + # See write_E function in libastro's dbfmt.c + return f'{dt.month:d}/{day:.12g}/{dt.year:d}' + else: + # See fs_date function in libastro's formats.c + return f'{dt.month:2d}/{day:02.6g}/{dt.year:-4d}' + + class EarthSatelliteBody(Body): """Body orbiting the Earth (besides the Moon, which is a SolarSystemBody). @@ -166,171 +180,91 @@ class EarthSatelliteBody(Body): ---------- name : str The name of the satellite + satellite : :class:`sgp4.api.Satrec` + Underlying SGP4 object doing the work with access to satellite parameters + orbit_number : int, optional + Number of revolutions / orbits the satellite has completed at given epoch + (only for backwards compatibility with EDB format, ignore otherwise) """ - def __init__(self, name): + def __init__(self, name, satellite, orbit_number=0): super().__init__(name) + self.satellite = satellite + # XXX We store this because C++ sgp4init doesn't take revnum and Satrec object is read-only + # This needs to go into the XEphem EDB string, which is still the de facto description + self.orbit_number = orbit_number + + @classmethod + def from_tle(cls, name, line1, line2): + """Build a Earth satellite body from a two-line element set (TLE). + + Parameters + ---------- + name : str + The name of the satellite + line1, line2 : str + The two lines of the TLE + """ + # Use the Python Satrec to validate the TLE first, since the C++ one has no error checking + SatrecPython.twoline2rv(line1, line2) + return cls(name, Satrec.twoline2rv(line1, line2)) def compute(self, frame, obstime, location=None): """Determine position of body at the given time and transform to `frame`.""" Body._check_location(frame) - # Create an SGP4 satellite object - self._sat = sgp4.model.Satellite() - self._sat.whichconst = sgp4.earth_gravity.wgs84 - self._sat.satnum = 1 - - # Extract date and time from the epoch - ep = copy.deepcopy(self._epoch) - ep.format = 'yday' - y = int(ep.value[:4]) - d = int(ep.value[5:8]) - h = int(ep.value[9:11]) - m = int(ep.value[12:14]) - s = float(ep.value[15:]) - self._sat.epochyr = y - self._sat.epochdays = d + (h + (m + s / 60.0) / 60.0) / 24.0 - ep.format = 'jd' - self._sat.jdsatepoch = ep.value - self._sat.bstar = self._drag - self._sat.ndot = self._decay - self._sat.nddot = self._nddot - self._sat.inclo = float(self._inc) - self._sat.nodeo = float(self._raan) - self._sat.ecco = self._e - self._sat.argpo = self._ap - self._sat.mo = self._M - self._sat.no_kozai = self._n / (24.0 * 60.0) * (2.0 * np.pi) - - # Compute position and velocity - date = obstime.iso - yr = int(date[:4]) - mon = int(date[5:7]) - day = int(date[8:10]) - h = int(date[11:13]) - m = int(date[14:16]) - s = float(date[17:]) - sgp4init(sgp4.earth_gravity.wgs84, False, self._sat.satnum, - self._sat.jdsatepoch-2433281.5, self._sat.bstar, - self._sat.ndot, self._sat.nddot, - self._sat.ecco, self._sat.argpo, self._sat.inclo, - self._sat.mo, self._sat.no, - self._sat.nodeo, self._sat) - p, v = self._sat.propagate(yr, mon, day, h, m, s) - - teme_p = CartesianRepresentation(p * u.km) + # Propagate the satellite according to SGP4 model (use array version if possible) + if obstime.shape == (): + e, r, v = self.satellite.sgp4(obstime.jd1, obstime.jd2) + else: + e, r, v = self.satellite.sgp4_array(obstime.jd1.ravel(), obstime.jd2.ravel()) + e = e.reshape(obstime.shape) + r = r.reshape(obstime.shape) + v = v.reshape(obstime.shape) + # Represent the position and velocity in the appropriate TEME frame + teme_p = CartesianRepresentation(r * u.km) teme_v = CartesianDifferential(v * u.km / u.s) teme = TEME(teme_p.with_differentials(teme_v), obstime=obstime) + # Convert to the desired output frame return teme.transform_to(frame) def writedb(self): - """ Create an XEphem catalogue entry. + """Create an XEphem database (EDB) entry for Earth satellite ("E"). - See http://www.clearskyinstitute.com/xephem/xephem.html - """ - dt = self._epoch.iso - yr = int(dt[:4]) - mon = int(dt[5:7]) - day = int(dt[8:10]) - h = int(dt[11:13]) - m = int(dt[14:16]) - s = float(dt[17:]) - - # The epoch field contains 3 dates, the actual epoch and the range - # of valid dates which xepehm sets to +/- 100 days. - epoch0 = '{0}/{1:.9}/{2}'.format(mon, day + ((h + (m + s/60.0) / 60.0) / 24.0), yr) - e = self._epoch + TimeDelta(-100, format='jd') - dt = str(e) - yr = int(dt[:4]) - mon = int(dt[5:7]) - day = int(dt[8:10]) - h = int(dt[11:13]) - m = int(dt[14:16]) - s = float(dt[17:]) - epoch1 = '{0}/{1:.6}/{2}'.format(mon, day + ((h + (m + s/60.0) / 60.0) / 24.0), yr) - e = e + TimeDelta(200, format='jd') - dt = str(e) - yr = int(dt[:4]) - mon = int(dt[5:7]) - day = int(dt[8:10]) - h = int(dt[11:13]) - m = int(dt[14:16]) - s = float(dt[17:]) - epoch2 = '{0}/{1:.6}/{2}'.format(mon, day + ((h + (m + s/60.0) / 60.0) / 24.0), yr) - - epoch = '{0}| {1}| {2}'.format(epoch0, epoch1, epoch2) - - return '{0},{1},{2},{3},{4},{5:0.6f},{6:0.2f},{7},{8},{9},{10},{11}'.\ - format(self.name, 'E', - epoch, - np.rad2deg(float(self._inc)), - np.rad2deg(float(self._raan)), - self._e, - np.rad2deg(float(self._ap)), - np.rad2deg(float(self._M)), - self._n, - self._decay, - self._orbit, - self._drag) - - -def _tle_to_float(tle_float): - """ Convert a TLE formatted float to a float.""" - dash = tle_float.find('-') - if dash == -1: - return float(tle_float) - else: - return float(tle_float[:dash] + "e-" + tle_float[dash+1:]) - - -def readtle(name, line1, line2): - """Create an EarthSatelliteBody object from a TLE description of an orbit. + See http://www.clearskyinstitute.com/xephem/help/xephem.html#mozTocId468501. - See https://en.wikipedia.org/wiki/Two-line_element_set - - Parameters - ---------- - name : str - Satellite name - - line1 : str - Line 1 of TLE - - line2 : str - Line 2 of TLE - """ - line1 = line1.lstrip() - line2 = line2.lstrip() - s = EarthSatelliteBody(name) - epochyr = int('20' + line1[18:20]) - epochdays = float(line1[20:32]) - - # Extract day, hour, min, sec from epochdays - ed = float(epochdays) - d = int(ed) - f = ed - d - h = int(f * 24.0) - f = (f * 24.0 - h) - m = int(f * 60.0) - sec = (f * 60.0 - m) * 60.0 - date = '{0:04d}:{1:03d}:{2:02d}:{3:02d}:{4:02}'.format(epochyr, d, h, m, sec) - - s._epoch = Time('2000-01-01 00:00:00.000') - - s._epoch = Time(date, format='yday') - s._epoch.format = 'iso' - - s._inc = np.deg2rad(_tle_to_float(line2[8:16])) - s._raan = np.deg2rad(_tle_to_float(line2[17:25])) - s._e = _tle_to_float('0.' + line2[26:33]) - s._ap = np.deg2rad(_tle_to_float(line2[34:42])) - s._M = np.deg2rad(_tle_to_float(line2[43:51])) - s._n = _tle_to_float(line2[52:63]) - s._decay = _tle_to_float(line1[33:43]) - s._nddot = _tle_to_float(line1[44:52]) - s._orbit = int(line2[63:68]) - s._drag = _tle_to_float('0.' + line1[53:61].strip()) - - return s + This attempts to be a faithful copy of the write_E function in + libastro's dbfmt.c, down to its use of single precision floats. + """ + sat = self.satellite + epoch = Time(sat.jdsatepoch, sat.jdsatepochF, format='jd') + # Extract orbital elements in XEphem units, and mostly single-precision. + # The trailing comments are corresponding XEphem variable names. + inclination = np.float32((sat.inclo * u.rad).to(u.deg).value) # inc + ra_asc_node = np.float32((sat.nodeo * u.rad).to(u.deg).value) # raan + eccentricity = np.float32(sat.ecco) # e + arg_perigee = np.float32((sat.argpo * u.rad).to(u.deg).value) # ap + mean_anomaly = np.float32((sat.mo * u.rad).to(u.deg).value) # M + # The mean motion uses double precision due to "sensitive differencing operation" + mean_motion = (sat.no_kozai * u.rad / u.minute).to(u.cycle / u.day).value # n + orbit_decay = (sat.ndot * u.rad / u.minute ** 2).to(u.cycle / u.day ** 2).value # decay + orbit_decay = np.float32(orbit_decay) + orbit_number = sat.revnum if sat.revnum else self.orbit_number # orbit + drag_coef = np.float32(sat.bstar) # drag + epoch_str = _time_to_edb(epoch, high_precision=True) # epoch + if abs(orbit_decay) > 0: + # The TLE is considered valid until the satellite period changes by more + # than 1%, but never for more than 100 days either side of the epoch. + # The mean motion is revs/day while decay is (revs/day)/day. + stable_days = np.clip(0.01 * mean_motion / abs(orbit_decay), None, 100) + epoch_start = _time_to_edb(epoch - stable_days) # startok + epoch_end = _time_to_edb(epoch + stable_days) # endok + valid_range = f'|{epoch_start}|{epoch_end}' + else: + valid_range = '' + return (f'{self.name},E,{epoch_str}{valid_range},{inclination:.8g},' + f'{ra_asc_node:.8g},{eccentricity:.8g},{arg_perigee:.8g},' + f'{mean_anomaly:.8g},{mean_motion:.12g},{orbit_decay:.8g},' + f'{orbit_number:d},{drag_coef:.8g}') class StationaryBody(Body): diff --git a/katpoint/stars.py b/katpoint/stars.py index e112039..4c11a76 100644 --- a/katpoint/stars.py +++ b/katpoint/stars.py @@ -27,10 +27,13 @@ registered at http://simbad.u-strasbg.fr/simbad/ were chosen. """ +import re + import numpy as np from astropy import units as u from astropy.coordinates import SkyCoord, Longitude, Latitude, ICRS from astropy.time import Time +from sgp4.api import Satrec, WGS72 from katpoint.body import FixedBody, EarthSatelliteBody @@ -156,6 +159,21 @@ stars = {} +def _edb_to_time(edb_epoch): + """Construct `Time` object from XEphem EDB epoch string.""" + match = re.match(r'\s*(\d{1,2})/(\d+\.?\d*)/\s*(\d+)', edb_epoch, re.ASCII) + if not match: + raise ValueError(f"Epoch string '{edb_epoch}' does not match EDB format 'MM/DD.DD+/YYYY'") + frac_day, int_day = np.modf(float(match[2])) + # Convert fractional day to hours, minutes and fractional seconds via Astropy machinery. + # Add arbitrary integer day to suppress ERFA warnings (will be replaced by actual day next). + rec = Time(59081.0, frac_day, scale='utc', format='mjd').ymdhms + rec['year'] = int(match[3]) + rec['month'] = int(match[1]) + rec['day'] = int(int_day) + return Time(rec, scale='utc') + + def readdb(line): """Unpacks a line of an xephem catalogue and creates a Body object. @@ -178,32 +196,30 @@ def readdb(line): elif fields[1][0] == 'E': # This is an Earth satellite - subfields = fields[2].split('|') - - # This is an earth satellite. - e = EarthSatelliteBody(name=fields[0]) - epoch = subfields[0].split('/') - yr = epoch[2] - mon = epoch[0] - h, day = np.modf(float(epoch[1])) - day = int(np.floor(day)) - m, h = np.modf(h * 24.0) - h = int(np.floor(h)) - s, m = np.modf(m * 60.0) - m = int(np.floor(m)) - s = s * 60.0 - e._epoch = Time('{0}-{1}-{2} {3:02d}:{4:02d}:{5}'.format(yr, mon, day, h, m, s), scale='utc') - e._inc = np.deg2rad(float(fields[3])) - e._raan = np.deg2rad(float(fields[4])) - e._e = float(fields[5]) - e._ap = np.deg2rad(float(fields[6])) - e._M = np.deg2rad(float(fields[7])) - e._n = float(fields[8]) - e._decay = float(fields[9]) - e._nddot = 0.0 - e._orbit = int(fields[10]) - e._drag = float(fields[11]) - return e + name = fields[0] + edb_epoch = _edb_to_time(fields[2].split('|')[0]) + # The SGP4 epoch is the number of days since 1949 December 31 00:00 UT (= JD 2433281.5) + # Be careful to preserve full 128-bit resolution to enable round-tripping of descriptions + sgp4_epoch = Time(edb_epoch.jd1 - 2433281.5, edb_epoch.jd2, format='jd').jd + (inclination, ra_asc_node, eccentricity, arg_perigee, mean_anomaly, + mean_motion, orbit_decay, orbit_number, drag_coef) = tuple(float(f) for f in fields[3:]) + sat = Satrec() + sat.sgp4init( + WGS72, # gravity model (TLEs are based on WGS72, therefore it is preferred to WGS84) + 'i', # 'a' = old AFSPC mode, 'i' = improved mode + 0, # satnum: Satellite number is not stored by XEphem, so pick an unused one + sgp4_epoch, # epoch + drag_coef, # bstar + (orbit_decay * u.cycle / u.day ** 2).to(u.rad / u.minute ** 2).value, # ndot + 0.0, # nddot (not used by SGP4) + eccentricity, # ecco + (arg_perigee * u.deg).to(u.rad).value, # argpo + (inclination * u.deg).to(u.rad).value, # inclo + (mean_anomaly * u.deg).to(u.rad).value, # mo + (mean_motion * u.cycle / u.day).to(u.rad / u.minute).value, # no_kozai + (ra_asc_node * u.deg).to(u.rad).value, # nodeo + ) + return EarthSatelliteBody(name, sat, int(orbit_number)) else: raise ValueError('Bogus: ' + line) diff --git a/katpoint/target.py b/katpoint/target.py index 3216a70..73b5bfc 100644 --- a/katpoint/target.py +++ b/katpoint/target.py @@ -28,7 +28,7 @@ from .ephem_extra import (is_iterable, lightspeed, deg2rad, angle_from_degrees, angle_from_hours) from .conversion import azel_to_enu from .projection import sphere_to_plane, sphere_to_ortho, plane_to_sphere -from .body import FixedBody, readtle, StationaryBody, SolarSystemBody, NullBody +from .body import FixedBody, SolarSystemBody, EarthSatelliteBody, StationaryBody, NullBody from .stars import star, readdb @@ -980,7 +980,7 @@ def construct_target_params(description): if tle_name != preferred_name: aliases.append(tle_name) try: - body = readtle(preferred_name, lines[1], lines[2]) + body = EarthSatelliteBody.from_tle(preferred_name, lines[1], lines[2]) except ValueError: raise ValueError("Target description '%s' contains malformed *tle* body" % description) diff --git a/katpoint/test/test_body.py b/katpoint/test/test_body.py index 61decec..8c8a6b7 100644 --- a/katpoint/test/test_body.py +++ b/katpoint/test/test_body.py @@ -22,14 +22,12 @@ """ import pytest -import numpy as np -from numpy.testing import assert_allclose from astropy import units as u from astropy.time import Time from astropy.coordinates import SkyCoord, ICRS, AltAz from astropy.coordinates import EarthLocation, Latitude, Longitude -from katpoint.body import FixedBody, SolarSystemBody, EarthSatelliteBody, readtle +from katpoint.body import FixedBody, SolarSystemBody, EarthSatelliteBody from katpoint.test.helper import check_separation try: @@ -46,15 +44,15 @@ def _get_fixed_body(ra_str, dec_str): return FixedBody('name', SkyCoord(ra=ra, dec=dec, frame=ICRS)) -TLE_NAME = ' GPS BIIA-21 (PRN 09) ' +TLE_NAME = 'GPS BIIA-21 (PRN 09)' TLE_LINE1 = '1 22700U 93042A 19266.32333151 .00000012 00000-0 10000-3 0 8057' TLE_LINE2 = '2 22700 55.4408 61.3790 0191986 78.1802 283.9935 2.00561720104282' TLE_TS = '2019-09-23 07:45:36.000' -TLE_AZ = '280:32:28.4266d' -# 1. 280:32:28.6053 Skyfield (0.23" error) -# 2. 280:32:29.675 Astropy 4.0.1 + PyOrbital for TEME (1.67" error) -# 3. 280:32:07.2d PyEphem (37" error) -TLE_EL = '-54:06:49.2409d' +TLE_AZ = '280:32:28.1892d' +# 1. 280:32:28.6053 Skyfield (0.43" error, was 0.23" with WGS84) +# 2. 280:32:29.675 Astropy 4.0.1 + PyOrbital for TEME (1.82" error) +# 3. 280:32:07.2 PyEphem (37" error) +TLE_EL = '-54:06:49.3936d' # 1. -54:06:49.0358 Skyfield # 2. -54:06:50.7456 Astropy 4.0.1 + PyOrbital for TEME # 3. -54:06:14.4 PyEphem @@ -62,8 +60,6 @@ def _get_fixed_body(ra_str, dec_str): LOCATION = EarthLocation(lat=10.0, lon=80.0, height=0.0) - - @pytest.mark.parametrize( "body, date_str, ra_str, dec_str, az_str, el_str, tol", [ @@ -80,7 +76,7 @@ def _get_fixed_body(ra_str, dec_str): (SolarSystemBody('Sun'), '2020-01-01 10:00:00.000', '7:56:36.7964h', '20:53:59.4553d', '234:53:19.4762d', '31:38:11.4248d', 1 * u.mas), # (PyEphem radec is geocentric) 234:53:20.8d 31:38:09.4d (PyEphem) - (readtle(TLE_NAME, TLE_LINE1, TLE_LINE2), TLE_TS, + (EarthSatelliteBody.from_tle(TLE_NAME, TLE_LINE1, TLE_LINE2), TLE_TS, '0:00:38.5009h', '00:03:56.0093d', TLE_AZ, TLE_EL, 1 * u.mas), ] ) @@ -108,60 +104,27 @@ def test_earth_satellite_vs_skyfield(): altaz = AltAz(alt=Latitude(alt.radians, unit=u.rad), az=Longitude(az.radians, unit=u.rad), obstime=obstime, location=TLE_LOCATION) - check_separation(altaz, TLE_AZ, TLE_EL, 0.25 * u.arcsec) + check_separation(altaz, TLE_AZ, TLE_EL, 0.5 * u.arcsec) def test_earth_satellite(): - sat = readtle(TLE_NAME, TLE_LINE1, TLE_LINE2) + body = EarthSatelliteBody.from_tle(TLE_NAME, TLE_LINE1, TLE_LINE2) + sat = body.satellite # Check that the EarthSatelliteBody object has the expected attribute values - assert str(sat._epoch) == '2019-09-23 07:45:35.842' - assert sat._inc == np.deg2rad(55.4408) - assert sat._raan == np.deg2rad(61.3790) - assert sat._e == 0.0191986 - assert sat._ap == np.deg2rad(78.1802) - assert sat._M == np.deg2rad(283.9935) - assert sat._n == 2.0056172 - assert sat._decay == 1.2e-07 - assert sat._orbit == 10428 - assert sat._drag == 1.e-04 - - # This is xephem database record that pyephem generates - xephem = ' GPS BIIA-21 (PRN 09) ,E,9/23.32333151/2019| 6/15.3242/2019| 1/1.32422/2020,' \ - '55.4408,61.379002,0.0191986,78.180199,283.9935,2.0056172,1.2e-07,10428,9.9999997e-05' - - rec = sat.writedb() - assert rec.split(',')[0] == xephem.split(',')[0] - assert rec.split(',')[1] == xephem.split(',')[1] - - assert (rec.split(',')[2].split('|')[0].split('/')[0] - == xephem.split(',')[2].split('|')[0].split('/')[0]) - assert_allclose(float(rec.split(',')[2].split('|')[0].split('/')[1]), - float(xephem.split(',')[2].split('|')[0].split('/')[1]), rtol=0, atol=0.5e-7) - assert (rec.split(',')[2].split('|')[0].split('/')[2] - == xephem.split(',')[2].split('|')[0].split('/')[2]) - - assert (rec.split(',')[2].split('|')[1].split('/')[0] - == xephem.split(',')[2].split('|')[1].split('/')[0]) - assert_allclose(float(rec.split(',')[2].split('|')[1].split('/')[1]), - float(xephem.split(',')[2].split('|')[1].split('/')[1]), rtol=0, atol=0.5e-2) - assert (rec.split(',')[2].split('|')[1].split('/')[2] - == xephem.split(',')[2].split('|')[1].split('/')[2]) - - assert (rec.split(',')[2].split('|')[2].split('/')[0] - == xephem.split(',')[2].split('|')[2].split('/')[0]) - assert_allclose(float(rec.split(',')[2].split('|')[2].split('/')[1]), - float(xephem.split(',')[2].split('|')[2].split('/')[1]), rtol=0, atol=0.5e-2) - assert (rec.split(',')[2].split('|')[2].split('/')[2] - == xephem.split(',')[2].split('|')[2].split('/')[2]) - - assert rec.split(',')[3] == xephem.split(',')[3] - - # pyephem adds spurious precision to these 3 fields - assert rec.split(',')[4] == xephem.split(',')[4][:6] - assert rec.split(',')[5][:7] == xephem.split(',')[5][:7] - assert rec.split(',')[6] == xephem.split(',')[6][:5] - - assert rec.split(',')[7] == xephem.split(',')[7] - assert rec.split(',')[8] == xephem.split(',')[8] - assert rec.split(',')[9] == xephem.split(',')[9] - assert rec.split(',')[10] == xephem.split(',')[10] + epoch = Time(sat.jdsatepoch, sat.jdsatepochF, format='jd') + assert epoch.iso == '2019-09-23 07:45:35.842' + assert sat.inclo * u.rad == 55.4408 * u.deg + assert sat.nodeo * u.rad == 61.3790 * u.deg + assert sat.ecco == 0.0191986 + assert sat.argpo * u.rad == 78.1802 * u.deg + assert sat.mo * u.rad == 283.9935 * u.deg + assert 2.0056172 * u.cycle / u.day == sat.no_kozai * u.rad / u.minute + assert sat.ndot * u.rad / u.minute ** 2 == 1.2e-07 * u.cycle / u.day ** 2 + assert sat.revnum == 10428 + assert sat.bstar == 1.e-04 + # This is the XEphem database record that PyEphem generates + xephem = ('GPS BIIA-21 (PRN 09),E,' + '9/23.32333151/2019| 6/15.3242/2019| 1/1.32422/2020,' + '55.4408,61.379002,0.0191986,78.180199,283.9935,' + '2.0056172,1.2e-07,10428,9.9999997e-05') + assert body.writedb() == xephem From 37ba8bded85d3214af1a760346cde156c1850013 Mon Sep 17 00:00:00 2001 From: Ludwig Schwardt Date: Fri, 21 Aug 2020 14:42:35 +0200 Subject: [PATCH 086/122] Depend on a more recent version of sgp4 This is what Astropy uses in its tests. --- gitlab-ci-requirements.txt | 2 +- setup.py | 2 +- system-requirements.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/gitlab-ci-requirements.txt b/gitlab-ci-requirements.txt index 29b7a20..6f582ee 100644 --- a/gitlab-ci-requirements.txt +++ b/gitlab-ci-requirements.txt @@ -11,7 +11,7 @@ pytest-cov pytest-pylint python-dotenv>=0.5.1 setuptools -sgp4 +sgp4>=2.3 sphinx sphinx-autobuild sphinx-rtd-theme diff --git a/setup.py b/setup.py index 11ecf6a..85d5ca6 100755 --- a/setup.py +++ b/setup.py @@ -55,7 +55,7 @@ install_requires=[ "astropy>=4.1rc1", "numpy", - "sgp4", + "sgp4>=2.3", ], tests_require=[ "pytest", diff --git a/system-requirements.txt b/system-requirements.txt index b4c8cd1..75a6222 100644 --- a/system-requirements.txt +++ b/system-requirements.txt @@ -2,5 +2,5 @@ astropy>=4.1rc1 numpy pytest pytest-cov -sgp4 +sgp4>=2.3 virtualenv From 5ad9e478b8fd2ccfae6483403e3122d081c1da12 Mon Sep 17 00:00:00 2001 From: Ludwig Schwardt Date: Fri, 21 Aug 2020 14:43:13 +0200 Subject: [PATCH 087/122] Document broken TLEs It's not obvious at first glance what is wrong with them. --- katpoint/test/test_target.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/katpoint/test/test_target.py b/katpoint/test/test_target.py index 48ec999..256ee2b 100644 --- a/katpoint/test/test_target.py +++ b/katpoint/test/test_target.py @@ -159,8 +159,10 @@ def test_construct_valid_target(description): 'radec J2000, 0.3', 'gal, 0.0', 'Zizou, radec cal, 1.4, 30.0, (1000.0, 2000.0, 1.0, 10.0)', + # TLE missing the first line ('tle, GPS BIIA-21 (PRN 09) \n' '2 22700 55.4408 61.3790 0191986 78.1802 283.9935 2.00561720104282\n'), + # TLE missing the satellite catalog number and classification on line 1 (', tle, GPS BIIA-22 (PRN 05) \n' '1 93054A {:02d}266.92814765 .00000062 00000-0 10000-3 0 289{:1d}\n' '2 22779 53.8943 118.4708 0081407 68.2645 292.7207 2.00558015103055\n' From 98f7f2a4bb39c65bf5461ea986ed8bc104437be9 Mon Sep 17 00:00:00 2001 From: Ludwig Schwardt Date: Fri, 21 Aug 2020 15:00:21 +0200 Subject: [PATCH 088/122] Minor MR fixes for SPAZA-120 - Revert to `import astropy.units as u`. Bruce's rule of thumb has prevailed :-) - Make skyield a test requirement. - Put ratio of units in parentheses as a good habit. --- katpoint/antenna.py | 2 +- katpoint/body.py | 4 ++-- katpoint/ephem_extra.py | 2 +- katpoint/stars.py | 2 +- katpoint/target.py | 2 +- katpoint/test/test_body.py | 2 +- katpoint/test/test_conversion.py | 2 +- katpoint/test/test_delay.py | 2 +- katpoint/test/test_target.py | 2 +- katpoint/test/test_timestamp.py | 2 +- requirements.txt | 2 +- setup.py | 1 + test-requirements.txt | 3 +++ 13 files changed, 16 insertions(+), 12 deletions(-) diff --git a/katpoint/antenna.py b/katpoint/antenna.py index 2a15f39..5943474 100644 --- a/katpoint/antenna.py +++ b/katpoint/antenna.py @@ -22,7 +22,7 @@ """ import numpy as np -from astropy import units as u +import astropy.units as u from astropy.coordinates import Latitude, Longitude, EarthLocation from .timestamp import Timestamp diff --git a/katpoint/body.py b/katpoint/body.py index 9c059de..61aedad 100644 --- a/katpoint/body.py +++ b/katpoint/body.py @@ -17,7 +17,7 @@ """A celestial body that can compute its sky position, inspired by PyEphem.""" import numpy as np -from astropy import units as u +import astropy.units as u from astropy.time import Time from astropy.coordinates import ICRS, AltAz from astropy.coordinates import solar_system_ephemeris, get_body @@ -222,7 +222,7 @@ def compute(self, frame, obstime, location=None): v = v.reshape(obstime.shape) # Represent the position and velocity in the appropriate TEME frame teme_p = CartesianRepresentation(r * u.km) - teme_v = CartesianDifferential(v * u.km / u.s) + teme_v = CartesianDifferential(v * (u.km / u.s)) teme = TEME(teme_p.with_differentials(teme_v), obstime=obstime) # Convert to the desired output frame return teme.transform_to(frame) diff --git a/katpoint/ephem_extra.py b/katpoint/ephem_extra.py index ab9a905..2e79c7d 100644 --- a/katpoint/ephem_extra.py +++ b/katpoint/ephem_extra.py @@ -17,7 +17,7 @@ """Enhancements to PyEphem.""" import numpy as np -from astropy import units as u +import astropy.units as u from astropy.coordinates import Angle # -------------------------------------------------------------------------------------------------- diff --git a/katpoint/stars.py b/katpoint/stars.py index 4c11a76..8ce221f 100644 --- a/katpoint/stars.py +++ b/katpoint/stars.py @@ -30,7 +30,7 @@ import re import numpy as np -from astropy import units as u +import astropy.units as u from astropy.coordinates import SkyCoord, Longitude, Latitude, ICRS from astropy.time import Time from sgp4.api import Satrec, WGS72 diff --git a/katpoint/target.py b/katpoint/target.py index 73b5bfc..9f53f34 100644 --- a/katpoint/target.py +++ b/katpoint/target.py @@ -17,7 +17,7 @@ """Target object used for pointing and flux density calculation.""" import numpy as np -from astropy import units as u +import astropy.units as u from astropy.coordinates import SkyCoord # High-level coordinates from astropy.coordinates import ICRS, Galactic, FK4, AltAz, CIRS # Low-level frames from astropy.coordinates import Latitude, Longitude, Angle # Angles diff --git a/katpoint/test/test_body.py b/katpoint/test/test_body.py index 8c8a6b7..70074f9 100644 --- a/katpoint/test/test_body.py +++ b/katpoint/test/test_body.py @@ -22,7 +22,7 @@ """ import pytest -from astropy import units as u +import astropy.units as u from astropy.time import Time from astropy.coordinates import SkyCoord, ICRS, AltAz from astropy.coordinates import EarthLocation, Latitude, Longitude diff --git a/katpoint/test/test_conversion.py b/katpoint/test/test_conversion.py index b736d98..3af364b 100644 --- a/katpoint/test/test_conversion.py +++ b/katpoint/test/test_conversion.py @@ -18,7 +18,7 @@ import pytest import numpy as np -from astropy import units as u +import astropy.units as u from astropy.coordinates import Angle import katpoint diff --git a/katpoint/test/test_delay.py b/katpoint/test/test_delay.py index c74a570..1beed09 100644 --- a/katpoint/test/test_delay.py +++ b/katpoint/test/test_delay.py @@ -21,7 +21,7 @@ import pytest import numpy as np -from astropy import units as u +import astropy.units as u from astropy.coordinates import Angle import katpoint diff --git a/katpoint/test/test_target.py b/katpoint/test/test_target.py index 256ee2b..ff923ee 100644 --- a/katpoint/test/test_target.py +++ b/katpoint/test/test_target.py @@ -22,7 +22,7 @@ import numpy as np import pytest -from astropy import units as u +import astropy.units as u from astropy.coordinates import Angle import katpoint diff --git a/katpoint/test/test_timestamp.py b/katpoint/test/test_timestamp.py index ad35c5a..8981f8c 100644 --- a/katpoint/test/test_timestamp.py +++ b/katpoint/test/test_timestamp.py @@ -21,7 +21,7 @@ import pytest import numpy as np -from astropy import units as u +import astropy.units as u from astropy.time import Time, TimeDelta import katpoint diff --git a/requirements.txt b/requirements.txt index 20193ce..d238b46 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,3 @@ astropy==4.1rc1 numpy -sgp4==2.4 +sgp4==2.12 diff --git a/setup.py b/setup.py index 85d5ca6..efa06da 100755 --- a/setup.py +++ b/setup.py @@ -60,4 +60,5 @@ tests_require=[ "pytest", "pytest-cov", + "skyfield", ]) diff --git a/test-requirements.txt b/test-requirements.txt index 9eb8444..9063766 100644 --- a/test-requirements.txt +++ b/test-requirements.txt @@ -1,6 +1,8 @@ attrs # via pytest +certifi # via skyfield coverage # via pytest-cov importlib-metadata # via pytest +jplephem==2.14 more-itertools # via pytest packaging # via pytest pluggy # via pytest @@ -9,5 +11,6 @@ pyparsing # via packaging pytest pytest-cov six # via packaging +skyfield==1.26 wcwidth # via pytest zipp # via importlib-metadata From 524d07e922cff6db520b2366bbec286de7df16f0 Mon Sep 17 00:00:00 2001 From: Ludwig Schwardt Date: Fri, 21 Aug 2020 16:19:27 +0200 Subject: [PATCH 089/122] Fix tests and safeguard TLE lines The test_stars tests still compared against old satellite parameters. The `Catalogue.add_tle` method curiously joins TLE lines with spaces, which breaks the parser. It should always be safe to strip them, so do that. --- katpoint/body.py | 2 ++ katpoint/test/test_stars.py | 25 ++++++++++++++----------- 2 files changed, 16 insertions(+), 11 deletions(-) diff --git a/katpoint/body.py b/katpoint/body.py index 61aedad..6d4f97e 100644 --- a/katpoint/body.py +++ b/katpoint/body.py @@ -205,6 +205,8 @@ def from_tle(cls, name, line1, line2): line1, line2 : str The two lines of the TLE """ + line1 = line1.strip() + line2 = line2.strip() # Use the Python Satrec to validate the TLE first, since the C++ one has no error checking SatrecPython.twoline2rv(line1, line2) return cls(name, Satrec.twoline2rv(line1, line2)) diff --git a/katpoint/test/test_stars.py b/katpoint/test/test_stars.py index fe33a2b..c25c764 100644 --- a/katpoint/test/test_stars.py +++ b/katpoint/test/test_stars.py @@ -16,7 +16,8 @@ """Tests for the stars module.""" -import numpy as np +import astropy.units as u +from astropy.time import Time from katpoint.stars import readdb from katpoint.body import EarthSatelliteBody, FixedBody @@ -29,16 +30,18 @@ def test_earth_satellite(): e = readdb(record) assert isinstance(e, EarthSatelliteBody) assert e.name == 'GPS BIIA-21 (PR' - assert str(e._epoch) == '2019-09-23 07:45:35.842' - assert e._inc == np.deg2rad(55.4408) - assert e._raan == np.deg2rad(61.379002) - assert e._e == 0.0191986 - assert e._ap == np.deg2rad(78.180199) - assert e._M == np.deg2rad(283.9935) - assert e._n == 2.0056172 - assert e._decay == 1.2e-07 - assert e._orbit == 10428 - assert e._drag == 9.9999997e-05 + sat = e.satellite + epoch = Time(sat.jdsatepoch, sat.jdsatepochF, format='jd') + assert epoch.iso == '2019-09-23 07:45:35.842' + assert sat.inclo * u.rad == 55.4408 * u.deg + assert sat.nodeo * u.rad == 61.379002 * u.deg + assert sat.ecco == 0.0191986 + assert sat.argpo * u.rad == 78.180199 * u.deg + assert sat.mo * u.rad == 283.9935 * u.deg + assert sat.no_kozai * u.rad / u.minute == 2.0056172 * u.cycle / u.day + assert sat.ndot * u.rad / u.minute ** 2 == 1.2e-07 * u.cycle / u.day ** 2 + assert e.orbit_number == 10428 # XXX This should eventually be sat.revnum + assert sat.bstar == 9.9999997e-05 def test_star(): From f9ceaa9cca6c8759c650c2a03b1a13ec98223ea9 Mon Sep 17 00:00:00 2001 From: Ludwig Schwardt Date: Fri, 21 Aug 2020 23:13:21 +0200 Subject: [PATCH 090/122] Move readdb inside relevant Bodies This is a better fit which brings together all EDB and SGP4 related routines in body.py. Rename readdb/writedb to from_edb/to_edb. Turn `from_edb` into a class method that serve as alternate constructor for FixedBody and EarthSatelliteBody. Rework stars.py to only construct a dict of FixedBodies. There is nothing left to test. Construct the dict directly in the module, without the need for a helper function. --- katpoint/body.py | 137 ++++++++++++++++++++++++-------- katpoint/catalogue.py | 4 +- katpoint/stars.py | 109 ++----------------------- katpoint/target.py | 14 ++-- katpoint/test/test_body.py | 31 +++++++- katpoint/test/test_catalogue.py | 4 +- katpoint/test/test_stars.py | 53 ------------ 7 files changed, 153 insertions(+), 199 deletions(-) delete mode 100644 katpoint/test/test_stars.py diff --git a/katpoint/body.py b/katpoint/body.py index 6d4f97e..fbda0f5 100644 --- a/katpoint/body.py +++ b/katpoint/body.py @@ -16,13 +16,15 @@ """A celestial body that can compute its sky position, inspired by PyEphem.""" +import re + import numpy as np import astropy.units as u from astropy.time import Time -from astropy.coordinates import ICRS, AltAz +from astropy.coordinates import ICRS, AltAz, Latitude, Longitude, SkyCoord from astropy.coordinates import solar_system_ephemeris, get_body from astropy.coordinates import TEME, CartesianDifferential, CartesianRepresentation -from sgp4.api import Satrec +from sgp4.api import Satrec, WGS72 from sgp4.model import Satrec as SatrecPython from .ephem_extra import angle_from_degrees @@ -56,6 +58,24 @@ def _check_location(frame): raise ValueError('Body needs a location to calculate (az, el) coordinates - ' 'did you specify an Antenna?') + @classmethod + def from_edb(cls, line): + """Build an appropriate `Body` from a line of an XEphem EDB catalogue. + + Only fixed positions without proper motions and earth satellites have + been implemented. + """ + try: + edb_type = line.split(',')[1][0] + except (AttributeError, IndexError): + raise ValueError(f'Failed parsing XEphem EDB line: {line}') + if edb_type == 'f': + return FixedBody.from_edb(line) + elif edb_type == 'E': + return EarthSatelliteBody.from_edb(line) + else: + raise ValueError(f'Unsupported XEphem EDB line: {line}') + def compute(self, frame, obstime, location): """Compute the coordinates of the body in the requested frame. @@ -94,6 +114,26 @@ def __init__(self, name, coord): super().__init__(name) self.coord = coord + @classmethod + def from_edb(cls, line): + """Construct a `FixedBody` from an XEphem database (EDB) entry.""" + fields = line.split(',') + name = fields[0] + ra = fields[2].split('|')[0] + dec = fields[3].split('|')[0] + ra = Longitude(ra, unit=u.hour) + dec = Latitude(dec, unit=u.deg) + return cls(name, SkyCoord(ra=ra, dec=dec, frame=ICRS)) + + def to_edb(self): + """Create an XEphem database (EDB) entry for fixed body ("f"). + + See http://www.clearskyinstitute.com/xephem/xephem.html + """ + icrs = self.coord.transform_to(ICRS) + return '{},f,{},{}'.format(self.name, icrs.ra.to_string(sep=':', unit=u.hour), + icrs.dec.to_string(sep=':', unit=u.deg)) + def compute(self, frame, obstime=None, location=None): """Compute the coordinates of the body in the requested frame. @@ -123,15 +163,6 @@ def compute(self, frame, obstime=None, location=None): coord = self.coord return coord.transform_to(frame) - def writedb(self): - """ Create an XEphem catalogue entry. - - See http://www.clearskyinstitute.com/xephem/xephem.html - """ - icrs = self.coord.transform_to(ICRS) - return '{},f,{},{}'.format(self.name, icrs.ra.to_string(sep=':', unit=u.hour), - icrs.dec.to_string(sep=':', unit=u.deg)) - class SolarSystemBody(Body): """A major Solar System body identified by name. @@ -155,6 +186,21 @@ def compute(self, frame, obstime, location=None): return gcrs.transform_to(frame) +def _edb_to_time(edb_epoch): + """Construct `Time` object from XEphem EDB epoch string.""" + match = re.match(r'\s*(\d{1,2})/(\d+\.?\d*)/\s*(\d+)', edb_epoch, re.ASCII) + if not match: + raise ValueError(f"Epoch string '{edb_epoch}' does not match EDB format 'MM/DD.DD+/YYYY'") + frac_day, int_day = np.modf(float(match[2])) + # Convert fractional day to hours, minutes and fractional seconds via Astropy machinery. + # Add arbitrary integer day to suppress ERFA warnings (will be replaced by actual day next). + rec = Time(59081.0, frac_day, scale='utc', format='mjd').ymdhms + rec['year'] = int(match[3]) + rec['month'] = int(match[1]) + rec['day'] = int(int_day) + return Time(rec, scale='utc') + + def _time_to_edb(t, high_precision=False): """Construct XEphem EDB epoch string from `Time` object.""" if not high_precision: @@ -196,7 +242,7 @@ def __init__(self, name, satellite, orbit_number=0): @classmethod def from_tle(cls, name, line1, line2): - """Build a Earth satellite body from a two-line element set (TLE). + """Build an `EarthSatelliteBody` from a two-line element set (TLE). Parameters ---------- @@ -211,25 +257,36 @@ def from_tle(cls, name, line1, line2): SatrecPython.twoline2rv(line1, line2) return cls(name, Satrec.twoline2rv(line1, line2)) - def compute(self, frame, obstime, location=None): - """Determine position of body at the given time and transform to `frame`.""" - Body._check_location(frame) - # Propagate the satellite according to SGP4 model (use array version if possible) - if obstime.shape == (): - e, r, v = self.satellite.sgp4(obstime.jd1, obstime.jd2) - else: - e, r, v = self.satellite.sgp4_array(obstime.jd1.ravel(), obstime.jd2.ravel()) - e = e.reshape(obstime.shape) - r = r.reshape(obstime.shape) - v = v.reshape(obstime.shape) - # Represent the position and velocity in the appropriate TEME frame - teme_p = CartesianRepresentation(r * u.km) - teme_v = CartesianDifferential(v * (u.km / u.s)) - teme = TEME(teme_p.with_differentials(teme_v), obstime=obstime) - # Convert to the desired output frame - return teme.transform_to(frame) - - def writedb(self): + @classmethod + def from_edb(cls, line): + """Build an `EarthSatelliteBody` from an XEphem database (EDB) entry.""" + fields = line.split(',') + name = fields[0] + edb_epoch = _edb_to_time(fields[2].split('|')[0]) + # The SGP4 epoch is the number of days since 1949 December 31 00:00 UT (= JD 2433281.5) + # Be careful to preserve full 128-bit resolution to enable round-tripping of descriptions + sgp4_epoch = Time(edb_epoch.jd1 - 2433281.5, edb_epoch.jd2, format='jd').jd + (inclination, ra_asc_node, eccentricity, arg_perigee, mean_anomaly, + mean_motion, orbit_decay, orbit_number, drag_coef) = tuple(float(f) for f in fields[3:]) + sat = Satrec() + sat.sgp4init( + WGS72, # gravity model (TLEs are based on WGS72, therefore it is preferred to WGS84) + 'i', # 'a' = old AFSPC mode, 'i' = improved mode + 0, # satnum: Satellite number is not stored by XEphem, so pick an unused one + sgp4_epoch, # epoch + drag_coef, # bstar + (orbit_decay * u.cycle / u.day ** 2).to(u.rad / u.minute ** 2).value, # ndot + 0.0, # nddot (not used by SGP4) + eccentricity, # ecco + (arg_perigee * u.deg).to(u.rad).value, # argpo + (inclination * u.deg).to(u.rad).value, # inclo + (mean_anomaly * u.deg).to(u.rad).value, # mo + (mean_motion * u.cycle / u.day).to(u.rad / u.minute).value, # no_kozai + (ra_asc_node * u.deg).to(u.rad).value, # nodeo + ) + return cls(name, sat, int(orbit_number)) + + def to_edb(self): """Create an XEphem database (EDB) entry for Earth satellite ("E"). See http://www.clearskyinstitute.com/xephem/help/xephem.html#mozTocId468501. @@ -268,6 +325,24 @@ def writedb(self): f'{mean_anomaly:.8g},{mean_motion:.12g},{orbit_decay:.8g},' f'{orbit_number:d},{drag_coef:.8g}') + def compute(self, frame, obstime, location=None): + """Determine position of body at the given time and transform to `frame`.""" + Body._check_location(frame) + # Propagate the satellite according to SGP4 model (use array version if possible) + if obstime.shape == (): + e, r, v = self.satellite.sgp4(obstime.jd1, obstime.jd2) + else: + e, r, v = self.satellite.sgp4_array(obstime.jd1.ravel(), obstime.jd2.ravel()) + e = e.reshape(obstime.shape) + r = r.reshape(obstime.shape) + v = v.reshape(obstime.shape) + # Represent the position and velocity in the appropriate TEME frame + teme_p = CartesianRepresentation(r * u.km) + teme_v = CartesianDifferential(v * (u.km / u.s)) + teme = TEME(teme_p.with_differentials(teme_v), obstime=obstime) + # Convert to the desired output frame + return teme.transform_to(frame) + class StationaryBody(Body): """Stationary body with fixed (az, el) coordinates. diff --git a/katpoint/catalogue.py b/katpoint/catalogue.py index 1622a34..732912e 100644 --- a/katpoint/catalogue.py +++ b/katpoint/catalogue.py @@ -24,7 +24,7 @@ from .target import Target from .timestamp import Timestamp -from .stars import stars +from .stars import STARS logger = logging.getLogger(__name__) @@ -306,7 +306,7 @@ def __init__(self, targets=None, tags=None, add_specials=False, add_stars=False, self.add(['%s, special' % (name,) for name in specials], tags) self.add('Zenith, azel, 0, 90', tags) if add_stars: - self.add(['%s, star' % (name,) for name in sorted(stars.keys())], tags) + self.add(['%s, star' % (name,) for name in sorted(STARS)], tags) if targets is None: targets = [] self.add(targets, tags) diff --git a/katpoint/stars.py b/katpoint/stars.py index 8ce221f..07e7262 100644 --- a/katpoint/stars.py +++ b/katpoint/stars.py @@ -27,18 +27,10 @@ registered at http://simbad.u-strasbg.fr/simbad/ were chosen. """ -import re +from katpoint.body import Body -import numpy as np -import astropy.units as u -from astropy.coordinates import SkyCoord, Longitude, Latitude, ICRS -from astropy.time import Time -from sgp4.api import Satrec, WGS72 -from katpoint.body import FixedBody, EarthSatelliteBody - - -db = """\ +db = """ Sirrah,f|S|B9,0:08:23.2|135.68,29:05:27|-162.95,2.07,2000,0 Caph,f|S|F2,0:09:10.1|523.39,59:09:01|-180.42,2.28,2000,0 Algenib,f|S|B2,0:13:14.2|4.7,15:11:01|-8.24,2.83,2000,0 @@ -156,95 +148,8 @@ Zubenelgenubi,f|S|A3,14 50 52.7773|-105.69,-16 02 29.798|-69.00,2.75,2000,0 """ -stars = {} - - -def _edb_to_time(edb_epoch): - """Construct `Time` object from XEphem EDB epoch string.""" - match = re.match(r'\s*(\d{1,2})/(\d+\.?\d*)/\s*(\d+)', edb_epoch, re.ASCII) - if not match: - raise ValueError(f"Epoch string '{edb_epoch}' does not match EDB format 'MM/DD.DD+/YYYY'") - frac_day, int_day = np.modf(float(match[2])) - # Convert fractional day to hours, minutes and fractional seconds via Astropy machinery. - # Add arbitrary integer day to suppress ERFA warnings (will be replaced by actual day next). - rec = Time(59081.0, frac_day, scale='utc', format='mjd').ymdhms - rec['year'] = int(match[3]) - rec['month'] = int(match[1]) - rec['day'] = int(int_day) - return Time(rec, scale='utc') - - -def readdb(line): - """Unpacks a line of an xephem catalogue and creates a Body object. - - Only fixed positions without proper motions and earth satellites have - been implemented. - """ - # Split line to fields - fields = line.split(',') - - if fields[1][0] == 'f': - - # This is a fixed position - name = fields[0] - ra = fields[2].split('|')[0] - dec = fields[3].split('|')[0] - ra = Longitude(ra, unit=u.hour) - dec = Latitude(dec, unit=u.deg) - return FixedBody(name, SkyCoord(ra=ra, dec=dec, frame=ICRS)) - - elif fields[1][0] == 'E': - - # This is an Earth satellite - name = fields[0] - edb_epoch = _edb_to_time(fields[2].split('|')[0]) - # The SGP4 epoch is the number of days since 1949 December 31 00:00 UT (= JD 2433281.5) - # Be careful to preserve full 128-bit resolution to enable round-tripping of descriptions - sgp4_epoch = Time(edb_epoch.jd1 - 2433281.5, edb_epoch.jd2, format='jd').jd - (inclination, ra_asc_node, eccentricity, arg_perigee, mean_anomaly, - mean_motion, orbit_decay, orbit_number, drag_coef) = tuple(float(f) for f in fields[3:]) - sat = Satrec() - sat.sgp4init( - WGS72, # gravity model (TLEs are based on WGS72, therefore it is preferred to WGS84) - 'i', # 'a' = old AFSPC mode, 'i' = improved mode - 0, # satnum: Satellite number is not stored by XEphem, so pick an unused one - sgp4_epoch, # epoch - drag_coef, # bstar - (orbit_decay * u.cycle / u.day ** 2).to(u.rad / u.minute ** 2).value, # ndot - 0.0, # nddot (not used by SGP4) - eccentricity, # ecco - (arg_perigee * u.deg).to(u.rad).value, # argpo - (inclination * u.deg).to(u.rad).value, # inclo - (mean_anomaly * u.deg).to(u.rad).value, # mo - (mean_motion * u.cycle / u.day).to(u.rad / u.minute).value, # no_kozai - (ra_asc_node * u.deg).to(u.rad).value, # nodeo - ) - return EarthSatelliteBody(name, sat, int(orbit_number)) - - else: - raise ValueError('Bogus: ' + line) - - -def _build_stars(): - """ Builds the default catalogue. - - The catalogue is loaded into a global array "stars" - """ - global stars - for line in db.strip().split('\n'): - s = readdb(line) - stars[s.name] = s - - -def star(name): - """ Get a record from the catalogue - """ - return stars[name] - - -# Build catalogue -_build_stars() - -# Remove the function for creating the default catalogue as it is no longer -# needed. -del _build_stars +STARS = {} +for _line in db.strip().split('\n'): + _body = Body.from_edb(_line.strip()) + STARS[_body.name] = _body +del _line, _body diff --git a/katpoint/target.py b/katpoint/target.py index 9f53f34..571ecda 100644 --- a/katpoint/target.py +++ b/katpoint/target.py @@ -28,8 +28,8 @@ from .ephem_extra import (is_iterable, lightspeed, deg2rad, angle_from_degrees, angle_from_hours) from .conversion import azel_to_enu from .projection import sphere_to_plane, sphere_to_ortho, plane_to_sphere -from .body import FixedBody, SolarSystemBody, EarthSatelliteBody, StationaryBody, NullBody -from .stars import star, readdb +from .body import Body, FixedBody, SolarSystemBody, EarthSatelliteBody, StationaryBody, NullBody +from .stars import STARS class NonAsciiError(ValueError): @@ -229,7 +229,7 @@ def description(self): elif self.body_type == 'tle': # Switch body type to xephem, as XEphem only saves bodies in xephem edb format (no TLE output) tags = tags.replace(tags.partition(' ')[0], 'xephem tle') - edb_string = self.body.writedb().replace(',', '~') + edb_string = self.body.to_edb().replace(',', '~') # Suppress name if it's the same as in the xephem db string edb_name = edb_string[:edb_string.index('~')] if edb_name == names: @@ -239,8 +239,8 @@ def description(self): elif self.body_type == 'xephem': # Replace commas in xephem string with tildes, to avoid clashing with main string structure - # Also remove extra spaces added into string by writedb - edb_string = '~'.join([edb_field.strip() for edb_field in self.body.writedb().split(',')]) + # Also remove extra spaces added into string by to_edb + edb_string = '~'.join([edb_field.strip() for edb_field in self.body.to_edb().split(',')]) # Suppress name if it's the same as in the xephem db string edb_name = edb_string[:edb_string.index('~')] if edb_name == names: @@ -997,7 +997,7 @@ def construct_target_params(description): elif body_type == 'star': star_name = ' '.join([w.capitalize() for w in preferred_name.split()]) try: - body = star(star_name) + body = STARS[star_name] except KeyError: raise ValueError("Target description '%s' contains unknown *star* '%s'" % (description, star_name)) @@ -1016,7 +1016,7 @@ def construct_target_params(description): if not (extra_name in aliases) and not (extra_name == preferred_name): aliases.append(extra_name) try: - body = readdb(edb_string) + body = Body.from_edb(edb_string) except ValueError: raise ValueError("Target description '%s' contains malformed *xephem* body" % description) # Add xephem body type as an extra tag, right after the main 'xephem' tag diff --git a/katpoint/test/test_body.py b/katpoint/test/test_body.py index 70074f9..9a35329 100644 --- a/katpoint/test/test_body.py +++ b/katpoint/test/test_body.py @@ -27,7 +27,7 @@ from astropy.coordinates import SkyCoord, ICRS, AltAz from astropy.coordinates import EarthLocation, Latitude, Longitude -from katpoint.body import FixedBody, SolarSystemBody, EarthSatelliteBody +from katpoint.body import Body, FixedBody, SolarSystemBody, EarthSatelliteBody from katpoint.test.helper import check_separation try: @@ -127,4 +127,31 @@ def test_earth_satellite(): '9/23.32333151/2019| 6/15.3242/2019| 1/1.32422/2020,' '55.4408,61.379002,0.0191986,78.180199,283.9935,' '2.0056172,1.2e-07,10428,9.9999997e-05') - assert body.writedb() == xephem + assert body.to_edb() == xephem + + record = 'GPS BIIA-21 (PR,E,9/23.32333151/2019| 6/15.3242/2019| 1/1.32422/2020,' \ + '55.4408,61.379002,0.0191986,78.180199,283.9935,2.0056172,1.2e-07,10428,9.9999997e-05' + e = Body.from_edb(record) + assert isinstance(e, EarthSatelliteBody) + assert e.name == 'GPS BIIA-21 (PR' + sat = e.satellite + epoch = Time(sat.jdsatepoch, sat.jdsatepochF, format='jd') + assert epoch.iso == '2019-09-23 07:45:35.842' + assert sat.inclo * u.rad == 55.4408 * u.deg + assert sat.nodeo * u.rad == 61.379002 * u.deg + assert sat.ecco == 0.0191986 + assert sat.argpo * u.rad == 78.180199 * u.deg + assert sat.mo * u.rad == 283.9935 * u.deg + assert sat.no_kozai * u.rad / u.minute == 2.0056172 * u.cycle / u.day + assert sat.ndot * u.rad / u.minute ** 2 == 1.2e-07 * u.cycle / u.day ** 2 + assert e.orbit_number == 10428 # XXX This should eventually be sat.revnum + assert sat.bstar == 9.9999997e-05 + + +def test_star(): + record = 'Sadr,f|S|F8,20:22:13.7|2.43,40:15:24|-0.93,2.23,2000,0' + e = Body.from_edb(record) + assert isinstance(e, FixedBody) + assert e.name == 'Sadr' + assert e.coord.ra.to_string(sep=':', unit='hour') == '20:22:13.7' + assert e.coord.dec.to_string(sep=':', unit='deg') == '40:15:24' diff --git a/katpoint/test/test_catalogue.py b/katpoint/test/test_catalogue.py index 0fb64a9..2f22783 100644 --- a/katpoint/test/test_catalogue.py +++ b/katpoint/test/test_catalogue.py @@ -82,7 +82,7 @@ def test_construct_catalogue(): """Test construction of catalogues.""" cat = katpoint.Catalogue(add_specials=True, add_stars=True, antenna=ANTENNA) num_targets_original = len(cat) - assert num_targets_original == len(katpoint.specials) + 1 + len(katpoint.stars.stars) + assert num_targets_original == len(katpoint.specials) + 1 + len(katpoint.stars.STARS) # Add target already in catalogue - no action cat.add(katpoint.Target('Sun, special')) num_targets = len(cat) @@ -165,7 +165,7 @@ def test_filter_catalogue(): def test_sort_catalogue(): """Test sorting of catalogues.""" cat = katpoint.Catalogue(add_specials=True, add_stars=True) - assert len(cat.targets) == len(katpoint.specials) + 1 + len(katpoint.stars.stars) + assert len(cat.targets) == len(katpoint.specials) + 1 + len(katpoint.stars.STARS) cat1 = cat.sort(key='name') assert cat1 == cat, 'Catalogue equality failed' assert cat1.targets[0].name == 'Acamar', 'Sorting on name failed' diff --git a/katpoint/test/test_stars.py b/katpoint/test/test_stars.py deleted file mode 100644 index c25c764..0000000 --- a/katpoint/test/test_stars.py +++ /dev/null @@ -1,53 +0,0 @@ -################################################################################ -# Copyright (c) 2009-2020, National Research Foundation (SARAO) -# -# Licensed under the BSD 3-Clause License (the "License"); you may not use -# this file except in compliance with the License. You may obtain a copy -# of the License at -# -# https://opensource.org/licenses/BSD-3-Clause -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -################################################################################ - -"""Tests for the stars module.""" - -import astropy.units as u -from astropy.time import Time - -from katpoint.stars import readdb -from katpoint.body import EarthSatelliteBody, FixedBody - - -def test_earth_satellite(): - record = 'GPS BIIA-21 (PR,E,9/23.32333151/2019| 6/15.3242/2019| 1/1.32422/2020,' \ - '55.4408,61.379002,0.0191986,78.180199,283.9935,2.0056172,1.2e-07,10428,9.9999997e-05' - - e = readdb(record) - assert isinstance(e, EarthSatelliteBody) - assert e.name == 'GPS BIIA-21 (PR' - sat = e.satellite - epoch = Time(sat.jdsatepoch, sat.jdsatepochF, format='jd') - assert epoch.iso == '2019-09-23 07:45:35.842' - assert sat.inclo * u.rad == 55.4408 * u.deg - assert sat.nodeo * u.rad == 61.379002 * u.deg - assert sat.ecco == 0.0191986 - assert sat.argpo * u.rad == 78.180199 * u.deg - assert sat.mo * u.rad == 283.9935 * u.deg - assert sat.no_kozai * u.rad / u.minute == 2.0056172 * u.cycle / u.day - assert sat.ndot * u.rad / u.minute ** 2 == 1.2e-07 * u.cycle / u.day ** 2 - assert e.orbit_number == 10428 # XXX This should eventually be sat.revnum - assert sat.bstar == 9.9999997e-05 - - -def test_star(): - record = 'Sadr,f|S|F8,20:22:13.7|2.43,40:15:24|-0.93,2.23,2000,0' - e = readdb(record) - assert isinstance(e, FixedBody) - assert e.name == 'Sadr' - assert e.coord.ra.to_string(sep=':', unit='hour') == '20:22:13.7' - assert e.coord.dec.to_string(sep=':', unit='deg') == '40:15:24' From 4fee3d1ca220c60ce82fa6e2743d3d8c7da02d84 Mon Sep 17 00:00:00 2001 From: Ludwig Schwardt Date: Sat, 22 Aug 2020 00:10:00 +0200 Subject: [PATCH 091/122] Streamline some EarthSatelliteBody tests --- katpoint/test/test_body.py | 57 ++++++++++++++++++-------------------- 1 file changed, 27 insertions(+), 30 deletions(-) diff --git a/katpoint/test/test_body.py b/katpoint/test/test_body.py index 9a35329..90fb7d5 100644 --- a/katpoint/test/test_body.py +++ b/katpoint/test/test_body.py @@ -107,45 +107,42 @@ def test_earth_satellite_vs_skyfield(): check_separation(altaz, TLE_AZ, TLE_EL, 0.5 * u.arcsec) +def _check_edb_E(sat, epoch_iso, inc, raan, e, ap, M, n, decay, drag): + """Check SGP4 object and EDB versions of standard orbital parameters.""" + epoch = Time(sat.jdsatepoch, sat.jdsatepochF, format='jd') + assert epoch.iso == epoch_iso + assert sat.inclo * u.rad == inc * u.deg + assert sat.nodeo * u.rad == raan * u.deg + assert sat.ecco == e + assert sat.argpo * u.rad == ap * u.deg + assert sat.mo * u.rad == M * u.deg + sat_n = (sat.no_kozai * u.rad / u.minute).to(u.cycle / u.day) + assert sat_n.value == pytest.approx(n, abs=1e-15) + assert sat.ndot * u.rad / u.minute ** 2 == decay * u.cycle / u.day ** 2 + assert sat.bstar == drag + + def test_earth_satellite(): body = EarthSatelliteBody.from_tle(TLE_NAME, TLE_LINE1, TLE_LINE2) - sat = body.satellite # Check that the EarthSatelliteBody object has the expected attribute values - epoch = Time(sat.jdsatepoch, sat.jdsatepochF, format='jd') - assert epoch.iso == '2019-09-23 07:45:35.842' - assert sat.inclo * u.rad == 55.4408 * u.deg - assert sat.nodeo * u.rad == 61.3790 * u.deg - assert sat.ecco == 0.0191986 - assert sat.argpo * u.rad == 78.1802 * u.deg - assert sat.mo * u.rad == 283.9935 * u.deg - assert 2.0056172 * u.cycle / u.day == sat.no_kozai * u.rad / u.minute - assert sat.ndot * u.rad / u.minute ** 2 == 1.2e-07 * u.cycle / u.day ** 2 - assert sat.revnum == 10428 - assert sat.bstar == 1.e-04 + _check_edb_E(body.satellite, epoch_iso='2019-09-23 07:45:35.842', + inc=55.4408, raan=61.3790, e=0.0191986, ap=78.1802, M=283.9935, + n=2.0056172, decay=1.2e-07, drag=1.e-04) + assert body.satellite.revnum == 10428 # This is the XEphem database record that PyEphem generates xephem = ('GPS BIIA-21 (PRN 09),E,' '9/23.32333151/2019| 6/15.3242/2019| 1/1.32422/2020,' '55.4408,61.379002,0.0191986,78.180199,283.9935,' '2.0056172,1.2e-07,10428,9.9999997e-05') assert body.to_edb() == xephem - - record = 'GPS BIIA-21 (PR,E,9/23.32333151/2019| 6/15.3242/2019| 1/1.32422/2020,' \ - '55.4408,61.379002,0.0191986,78.180199,283.9935,2.0056172,1.2e-07,10428,9.9999997e-05' - e = Body.from_edb(record) - assert isinstance(e, EarthSatelliteBody) - assert e.name == 'GPS BIIA-21 (PR' - sat = e.satellite - epoch = Time(sat.jdsatepoch, sat.jdsatepochF, format='jd') - assert epoch.iso == '2019-09-23 07:45:35.842' - assert sat.inclo * u.rad == 55.4408 * u.deg - assert sat.nodeo * u.rad == 61.379002 * u.deg - assert sat.ecco == 0.0191986 - assert sat.argpo * u.rad == 78.180199 * u.deg - assert sat.mo * u.rad == 283.9935 * u.deg - assert sat.no_kozai * u.rad / u.minute == 2.0056172 * u.cycle / u.day - assert sat.ndot * u.rad / u.minute ** 2 == 1.2e-07 * u.cycle / u.day ** 2 - assert e.orbit_number == 10428 # XXX This should eventually be sat.revnum - assert sat.bstar == 9.9999997e-05 + # Check some round-tripping + body2 = Body.from_edb(xephem) + assert isinstance(body2, EarthSatelliteBody) + assert body2.to_edb() == xephem + _check_edb_E(body2.satellite, epoch_iso='2019-09-23 07:45:35.842', + inc=55.4408, raan=61.379002, e=0.0191986, ap=78.180199, M=283.9935, + n=2.0056172, decay=1.2e-07, drag=9.9999997e-05) + assert body2.orbit_number == 10428 # XXX This should eventually be sat.revnum def test_star(): From 05e9d71da68f6f03f8150d677393601b485fc634 Mon Sep 17 00:00:00 2001 From: Ludwig Schwardt Date: Mon, 24 Aug 2020 13:56:49 +0200 Subject: [PATCH 092/122] TLE targets now support array-valued timestamps Enable the tests and promptly fix the array reshaping so that it actually works (r and v are 3-D vectors and `sgp4_array` produces N x 3 arrays for N timestamps). --- katpoint/body.py | 4 ++-- katpoint/test/test_target.py | 3 +-- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/katpoint/body.py b/katpoint/body.py index fbda0f5..4067336 100644 --- a/katpoint/body.py +++ b/katpoint/body.py @@ -334,8 +334,8 @@ def compute(self, frame, obstime, location=None): else: e, r, v = self.satellite.sgp4_array(obstime.jd1.ravel(), obstime.jd2.ravel()) e = e.reshape(obstime.shape) - r = r.reshape(obstime.shape) - v = v.reshape(obstime.shape) + r = r.T.reshape((3,) + obstime.shape) + v = v.T.reshape((3,) + obstime.shape) # Represent the position and velocity in the appropriate TEME frame teme_p = CartesianRepresentation(r * u.km) teme_v = CartesianDifferential(v * (u.km / u.s)) diff --git a/katpoint/test/test_target.py b/katpoint/test/test_target.py index ff923ee..af3cb9a 100644 --- a/katpoint/test/test_target.py +++ b/katpoint/test/test_target.py @@ -233,9 +233,8 @@ def _array_vs_scalar(func, array_in, sky_coord=False, pre_shape=(), post_shape=( np.testing.assert_array_equal(array_slice, np.asarray(scalar)) -# XXX TLE_TARGET does not support array timestamps yet @pytest.mark.parametrize("description", ['azel, 10, -10', 'radec, 20, -20', - 'gal, 30, -30', 'Sun, special']) + 'gal, 30, -30', 'Sun, special', TLE_TARGET]) def test_array_valued_methods(description): """Test array-valued methods, comparing output against corresponding scalar versions.""" offsets = np.array([[[0, 1, 2, 3], [4, 5, 6, 7]]]) From 1383eaca4df5f98cc29f1580e5aceace315f6f11 Mon Sep 17 00:00:00 2001 From: Ludwig Schwardt Date: Mon, 24 Aug 2020 17:52:26 +0200 Subject: [PATCH 093/122] Change TLE description to comma-separated lines The original "tle" body type was an ephemeral format (pun intended) that was only officially used in unit tests and as intermediate format in the `Catalogue.add_tle` method. Its description string contains newlines, which breaks the assumption of one target per line, but matches the raw format in TLE files ("name \n line1 \n line2 \n"). The archived datasets store TLE targets in XEphem EDB format instead. Restore this body type to the glory it deserves. Instead of newlines, store the two lines with a comma in between, which matches azel, radec, etc and also opens the door for satellite flux models. The old format is now invalid. The name is found in the usual spot in the string (no more tle_name...) and if absent, call the satellite "unnamed". It is an open question whether the TLE description should be one or two fields. Two fields make it compatible with other body types and have a natural connection with the two lines of the TLE. However, there is an XML format looming in the near future of satellite descriptions (search "Orbit Mean-Elements Message (OMM)") that is a better match for one field. In the end I've gone with two because it simplifies flux models. And an XML version could just have an empty second field. This uses the reasonably fresh sgp4 `export_tle` function which appeared in version 2.6. However, I missed that the accelerated sgp4init function needs 2.7 so jump ahead to that. This addresses JIRA ticket SPAZA-121. --- gitlab-ci-requirements.txt | 2 +- katpoint/body.py | 5 +++++ katpoint/catalogue.py | 9 +++++---- katpoint/target.py | 26 ++++++++------------------ katpoint/test/test_body.py | 1 + katpoint/test/test_target.py | 24 +++++++++++++++--------- setup.py | 2 +- system-requirements.txt | 2 +- 8 files changed, 37 insertions(+), 34 deletions(-) diff --git a/gitlab-ci-requirements.txt b/gitlab-ci-requirements.txt index 6f582ee..a6a76e8 100644 --- a/gitlab-ci-requirements.txt +++ b/gitlab-ci-requirements.txt @@ -11,7 +11,7 @@ pytest-cov pytest-pylint python-dotenv>=0.5.1 setuptools -sgp4>=2.3 +sgp4>=2.7 sphinx sphinx-autobuild sphinx-rtd-theme diff --git a/katpoint/body.py b/katpoint/body.py index 4067336..05c7dc0 100644 --- a/katpoint/body.py +++ b/katpoint/body.py @@ -26,6 +26,7 @@ from astropy.coordinates import TEME, CartesianDifferential, CartesianRepresentation from sgp4.api import Satrec, WGS72 from sgp4.model import Satrec as SatrecPython +from sgp4.exporter import export_tle from .ephem_extra import angle_from_degrees @@ -257,6 +258,10 @@ def from_tle(cls, name, line1, line2): SatrecPython.twoline2rv(line1, line2) return cls(name, Satrec.twoline2rv(line1, line2)) + def to_tle(self): + """Export satellite parameters as a TLE in the form `(line1, line2)`.""" + return export_tle(self.satellite) + @classmethod def from_edb(cls, line): """Build an `EarthSatelliteBody` from an XEphem database (EDB) entry.""" diff --git a/katpoint/catalogue.py b/katpoint/catalogue.py index 732912e..da793f8 100644 --- a/katpoint/catalogue.py +++ b/katpoint/catalogue.py @@ -493,7 +493,8 @@ def add_tle(self, lines, tags=None): continue tle += [line] if len(tle) == 3: - targets.append('tle,' + ' '.join(tle)) + name, line1, line2 = [raw_line.strip() for raw_line in tle] + targets.append(f'{name}, tle, {line1}, {line2}') tle = [] if len(tle) > 0: logger.warning('Did not receive a multiple of three lines when constructing TLEs') @@ -501,14 +502,14 @@ def add_tle(self, lines, tags=None): # Check TLE epochs and warn if some are too far in past or future, which would make TLE inaccurate right now max_epoch_diff_days, num_outdated, worst = 0, 0, None for target in targets: + name, _, line1, line2 = target.split(',') # Extract name, epoch and mean motion (revolutions per day) - name = target.split('\n')[0][4:].strip() - epoch_year, epoch_day = float(target.split('\n')[1][19:21]), float(target.split('\n')[1][21:33]) + epoch_year, epoch_day = float(line1[19:21]), float(line1[21:33]) epoch_year = epoch_year + 1900 if epoch_year >= 57 else epoch_year + 2000 frac_epoch_day, int_epoch_day = np.modf(epoch_day) yday_date = '{:4d}:{:03d}'.format(int(epoch_year), int(int_epoch_day)) epoch = Time(yday_date, format='yday') + frac_epoch_day - revs_per_day = float(target.split('\n')[2][53:64]) + revs_per_day = float(line2[53:64]) # Use orbital period to distinguish near-earth and deep-space objects (which have different accuracies) orbital_period_mins = 24. / revs_per_day * 60. now = Time.now() diff --git a/katpoint/target.py b/katpoint/target.py index 571ecda..08e2040 100644 --- a/katpoint/target.py +++ b/katpoint/target.py @@ -227,15 +227,9 @@ def description(self): fields += [fluxinfo] elif self.body_type == 'tle': - # Switch body type to xephem, as XEphem only saves bodies in xephem edb format (no TLE output) - tags = tags.replace(tags.partition(' ')[0], 'xephem tle') - edb_string = self.body.to_edb().replace(',', '~') - # Suppress name if it's the same as in the xephem db string - edb_name = edb_string[:edb_string.index('~')] - if edb_name == names: - fields = [tags, edb_string] - else: - fields = [names, tags, edb_string] + fields += self.body.to_tle() + if fluxinfo: + fields += [fluxinfo] elif self.body_type == 'xephem': # Replace commas in xephem string with tildes, to avoid clashing with main string structure @@ -970,17 +964,13 @@ def construct_target_params(description): b=Latitude(b, unit=u.deg), frame=Galactic)) elif body_type == 'tle': - lines = fields[-1].split('\n') - if len(lines) != 3: - raise ValueError("Target description '%s' contains *tle* body without the expected three lines" - % description) - tle_name = lines[0].strip() + if len(fields) < 4: + raise ValueError(f"Target description '{description}' contains *tle* body " + "without the expected two comma-separated lines") if not preferred_name: - preferred_name = tle_name - if tle_name != preferred_name: - aliases.append(tle_name) + preferred_name = 'Unnamed Satellite' try: - body = EarthSatelliteBody.from_tle(preferred_name, lines[1], lines[2]) + body = EarthSatelliteBody.from_tle(preferred_name, fields[2], fields[3]) except ValueError: raise ValueError("Target description '%s' contains malformed *tle* body" % description) diff --git a/katpoint/test/test_body.py b/katpoint/test/test_body.py index 90fb7d5..dfd8b0c 100644 --- a/katpoint/test/test_body.py +++ b/katpoint/test/test_body.py @@ -124,6 +124,7 @@ def _check_edb_E(sat, epoch_iso, inc, raan, e, ap, M, n, decay, drag): def test_earth_satellite(): body = EarthSatelliteBody.from_tle(TLE_NAME, TLE_LINE1, TLE_LINE2) + assert body.to_tle() == (TLE_LINE1, TLE_LINE2) # Check that the EarthSatelliteBody object has the expected attribute values _check_edb_E(body.satellite, epoch_iso='2019-09-23 07:45:35.842', inc=55.4408, raan=61.3790, e=0.0191986, ap=78.1802, M=283.9935, diff --git a/katpoint/test/test_target.py b/katpoint/test/test_target.py index af3cb9a..9030932 100644 --- a/katpoint/test/test_target.py +++ b/katpoint/test/test_target.py @@ -31,9 +31,9 @@ # Use the current year in TLE epochs to avoid potential crashes due to expired TLEs YY = time.localtime().tm_year % 100 -TLE_TARGET = ('tle, GPS BIIA-21 (PRN 09) \n' - '1 22700U 93042A {:02d}266.32333151 .00000012 00000-0 10000-3 0 805{:1d}\n' - '2 22700 55.4408 61.3790 0191986 78.1802 283.9935 2.00561720104282\n' +TLE_TARGET = ('GPS BIIA-21 (PRN 09), tle, ' + '1 22700U 93042A {:02d}266.32333151 .00000012 00000-0 10000-3 0 805{:1d}, ' + '2 22700 55.4408 61.3790 0191986 78.1802 283.9935 2.00561720104282' .format(YY, (YY // 10 + YY - 7 + 4) % 10)) @@ -125,7 +125,8 @@ def test_add_tags(self): 'Zizou, radec cal, 1.4, 30.0, (1000.0 2000.0 1.0 10.0)', 'Fluffy | *Dinky, radec, 12.5, -50.0, (1.0 2.0 1.0 2.0 3.0 4.0)', TLE_TARGET, - ', ' + TLE_TARGET, + # Unnamed satellite + TLE_TARGET[TLE_TARGET.find(','):], 'Sun, special', 'Nothing, special', 'Moon | Luna, special solarbody', @@ -159,13 +160,18 @@ def test_construct_valid_target(description): 'radec J2000, 0.3', 'gal, 0.0', 'Zizou, radec cal, 1.4, 30.0, (1000.0, 2000.0, 1.0, 10.0)', - # TLE missing the first line + # The old TLE format containing three newline-separated lines straight from TLE file ('tle, GPS BIIA-21 (PRN 09) \n' - '2 22700 55.4408 61.3790 0191986 78.1802 283.9935 2.00561720104282\n'), + '1 22700U 93042A {:02d}266.32333151 .00000012 00000-0 10000-3 0 805{:1d}\n' + '2 22700 55.4408 61.3790 0191986 78.1802 283.9935 2.00561720104282\n' + .format(YY, (YY // 10 + YY - 7 + 4) % 10)), + # TLE missing the first line + ('GPS BIIA-21 (PRN 09), tle, ' + '2 22700 55.4408 61.3790 0191986 78.1802 283.9935 2.00561720104282'), # TLE missing the satellite catalog number and classification on line 1 - (', tle, GPS BIIA-22 (PRN 05) \n' - '1 93054A {:02d}266.92814765 .00000062 00000-0 10000-3 0 289{:1d}\n' - '2 22779 53.8943 118.4708 0081407 68.2645 292.7207 2.00558015103055\n' + ('GPS BIIA-22 (PRN 05), tle, ' + '1 93054A {:02d}266.92814765 .00000062 00000-0 10000-3 0 289{:1d}, ' + '2 22779 53.8943 118.4708 0081407 68.2645 292.7207 2.00558015103055' .format(YY, (YY // 10 + YY - 7 + 5) % 10)), 'Sunny, special', 'Slinky, star', diff --git a/setup.py b/setup.py index efa06da..b20afd8 100755 --- a/setup.py +++ b/setup.py @@ -55,7 +55,7 @@ install_requires=[ "astropy>=4.1rc1", "numpy", - "sgp4>=2.3", + "sgp4>=2.7", ], tests_require=[ "pytest", diff --git a/system-requirements.txt b/system-requirements.txt index 75a6222..c7c0c4d 100644 --- a/system-requirements.txt +++ b/system-requirements.txt @@ -2,5 +2,5 @@ astropy>=4.1rc1 numpy pytest pytest-cov -sgp4>=2.3 +sgp4>=2.7 virtualenv From 8656581e67ef81695509be827fee5e658611e4ba Mon Sep 17 00:00:00 2001 From: Ludwig Schwardt Date: Tue, 25 Aug 2020 11:30:29 +0200 Subject: [PATCH 094/122] Do TLE epoch check on underlying sgp4 object Instead of parsing the TLE strings and extracting the epoch and mean motion ourselves, rely on the parameters stored on the sgp4 Satrec object. This improves robustness and maintainability. Expose the TLE epoch on EarthSatelliteBody as an Astropy `Time`, since we need this in two places by now. Instantiate the katpoint Targets up front in `add_tle()`, which gives us access to Satrec via the EarthSatelliteBody. The targets would have been constructed in `add()` next anyway, with the same level of error handling. --- katpoint/body.py | 7 ++++++- katpoint/catalogue.py | 25 ++++++++++--------------- 2 files changed, 16 insertions(+), 16 deletions(-) diff --git a/katpoint/body.py b/katpoint/body.py index 05c7dc0..556890e 100644 --- a/katpoint/body.py +++ b/katpoint/body.py @@ -241,6 +241,11 @@ def __init__(self, name, satellite, orbit_number=0): # This needs to go into the XEphem EDB string, which is still the de facto description self.orbit_number = orbit_number + @property + def epoch(self): + """The moment in time when the satellite model is true, as an Astropy `Time`.""" + return Time(self.satellite.jdsatepoch, self.satellite.jdsatepochF, format='jd') + @classmethod def from_tle(cls, name, line1, line2): """Build an `EarthSatelliteBody` from a two-line element set (TLE). @@ -300,7 +305,7 @@ def to_edb(self): libastro's dbfmt.c, down to its use of single precision floats. """ sat = self.satellite - epoch = Time(sat.jdsatepoch, sat.jdsatepochF, format='jd') + epoch = self.epoch # Extract orbital elements in XEphem units, and mostly single-precision. # The trailing comments are corresponding XEphem variable names. inclination = np.float32((sat.inclo * u.rad).to(u.deg).value) # inc diff --git a/katpoint/catalogue.py b/katpoint/catalogue.py index da793f8..2ce9aeb 100644 --- a/katpoint/catalogue.py +++ b/katpoint/catalogue.py @@ -20,6 +20,7 @@ from collections import defaultdict import numpy as np +import astropy.units as u from astropy.time import Time from .target import Target @@ -494,7 +495,7 @@ def add_tle(self, lines, tags=None): tle += [line] if len(tle) == 3: name, line1, line2 = [raw_line.strip() for raw_line in tle] - targets.append(f'{name}, tle, {line1}, {line2}') + targets.append(Target(f'{name}, tle, {line1}, {line2}')) tle = [] if len(tle) > 0: logger.warning('Did not receive a multiple of three lines when constructing TLEs') @@ -502,28 +503,22 @@ def add_tle(self, lines, tags=None): # Check TLE epochs and warn if some are too far in past or future, which would make TLE inaccurate right now max_epoch_diff_days, num_outdated, worst = 0, 0, None for target in targets: - name, _, line1, line2 = target.split(',') - # Extract name, epoch and mean motion (revolutions per day) - epoch_year, epoch_day = float(line1[19:21]), float(line1[21:33]) - epoch_year = epoch_year + 1900 if epoch_year >= 57 else epoch_year + 2000 - frac_epoch_day, int_epoch_day = np.modf(epoch_day) - yday_date = '{:4d}:{:03d}'.format(int(epoch_year), int(int_epoch_day)) - epoch = Time(yday_date, format='yday') + frac_epoch_day - revs_per_day = float(line2[53:64]) - # Use orbital period to distinguish near-earth and deep-space objects (which have different accuracies) - orbital_period_mins = 24. / revs_per_day * 60. + # Use orbital period to distinguish near-earth and deep-space objects + # (which have different accuracies) + mean_motion = target.body.satellite.no_kozai * u.rad / u.minute + orbital_period = 1 * u.cycle / mean_motion now = Time.now() - epoch_diff_days = np.abs(now - epoch).jd - direction = 'past' if epoch < now else 'future' + epoch_diff_days = np.abs(now - target.body.epoch).jd + direction = 'past' if target.body.epoch < now else 'future' # Near-earth models should be good for about a week (conservative estimate) - if orbital_period_mins < 225 and epoch_diff_days > 7: + if orbital_period < 225 * u.minute and epoch_diff_days > 7: num_outdated += 1 if epoch_diff_days > max_epoch_diff_days: worst = "Worst case: TLE epoch for '%s' is %d days in %s, should be <= 7 for near-earth model" % \ (name, epoch_diff_days, direction) max_epoch_diff_days = epoch_diff_days # Deep-space models are more accurate (three weeks for a conservative estimate) - if orbital_period_mins >= 225 and epoch_diff_days > 21: + if orbital_period >= 225 * u.minute and epoch_diff_days > 21: num_outdated += 1 if epoch_diff_days > max_epoch_diff_days: worst = "Worst case: TLE epoch for '%s' is %d days in %s, should be <= 21 for deep-space model" % \ From fa1b1da5eb852332bba273c44ceaa784e87c0b15 Mon Sep 17 00:00:00 2001 From: Ludwig Schwardt Date: Tue, 25 Aug 2020 11:57:28 +0200 Subject: [PATCH 095/122] Remove TLE epoch kludge in tests This was added to avoid a crash in PyEphem due to expired TLEs. The sgp4 library does not seem to have similar concerns. I've even changed the epoch to 1960 without a hitch. --- katpoint/test/test_catalogue.py | 5 +---- katpoint/test/test_target.py | 18 ++++++------------ 2 files changed, 7 insertions(+), 16 deletions(-) diff --git a/katpoint/test/test_catalogue.py b/katpoint/test/test_catalogue.py index 2f22783..97a886d 100644 --- a/katpoint/test/test_catalogue.py +++ b/katpoint/test/test_catalogue.py @@ -22,8 +22,6 @@ import katpoint -# Use the current year in TLE epochs to avoid pyephem crash due to expired TLEs -YY = katpoint.Timestamp().time.ymdhms[0] % 100 FLUX_TARGET = katpoint.Target('flux, radec, 0.0, 0.0, (1.0 2.0 2.0 0.0 0.0)') ANTENNA = katpoint.Antenna('XDM, -25:53:23.05075, 27:41:03.36453, 1406.1086, 15.0') TIMESTAMP = '2009/06/14 12:34:56' @@ -106,8 +104,7 @@ def test_construct_catalogue(): assert cat['Non-existent'] is None, 'Lookup of non-existent target failed' tle_lines = ['# Comment ignored\n', 'GPS BIIA-21 (PRN 09) \n', - '1 22700U 93042A %02d266.32333151 .00000012 00000-0 10000-3 0 805%1d\n' - % (YY, (YY // 10 + YY - 7 + 4) % 10), + '1 22700U 93042A 07266.32333151 .00000012 00000-0 10000-3 0 8054\n', '2 22700 55.4408 61.3790 0191986 78.1802 283.9935 2.00561720104282\n'] cat.add_tle(tle_lines, 'tle') edb_lines = ['# Comment ignored\n', diff --git a/katpoint/test/test_target.py b/katpoint/test/test_target.py index 9030932..92eee96 100644 --- a/katpoint/test/test_target.py +++ b/katpoint/test/test_target.py @@ -16,7 +16,6 @@ """Tests for the target module.""" -import time import pickle from contextlib import contextmanager @@ -29,12 +28,9 @@ from katpoint.test.helper import check_separation -# Use the current year in TLE epochs to avoid potential crashes due to expired TLEs -YY = time.localtime().tm_year % 100 TLE_TARGET = ('GPS BIIA-21 (PRN 09), tle, ' - '1 22700U 93042A {:02d}266.32333151 .00000012 00000-0 10000-3 0 805{:1d}, ' - '2 22700 55.4408 61.3790 0191986 78.1802 283.9935 2.00561720104282' - .format(YY, (YY // 10 + YY - 7 + 4) % 10)) + '1 22700U 93042A 07266.32333151 .00000012 00000-0 10000-3 0 8054, ' + '2 22700 55.4408 61.3790 0191986 78.1802 283.9935 2.00561720104282') class TestTargetConstruction: @@ -162,17 +158,15 @@ def test_construct_valid_target(description): 'Zizou, radec cal, 1.4, 30.0, (1000.0, 2000.0, 1.0, 10.0)', # The old TLE format containing three newline-separated lines straight from TLE file ('tle, GPS BIIA-21 (PRN 09) \n' - '1 22700U 93042A {:02d}266.32333151 .00000012 00000-0 10000-3 0 805{:1d}\n' - '2 22700 55.4408 61.3790 0191986 78.1802 283.9935 2.00561720104282\n' - .format(YY, (YY // 10 + YY - 7 + 4) % 10)), + '1 22700U 93042A 07266.32333151 .00000012 00000-0 10000-3 0 8054\n' + '2 22700 55.4408 61.3790 0191986 78.1802 283.9935 2.00561720104282\n'), # TLE missing the first line ('GPS BIIA-21 (PRN 09), tle, ' '2 22700 55.4408 61.3790 0191986 78.1802 283.9935 2.00561720104282'), # TLE missing the satellite catalog number and classification on line 1 ('GPS BIIA-22 (PRN 05), tle, ' - '1 93054A {:02d}266.92814765 .00000062 00000-0 10000-3 0 289{:1d}, ' - '2 22779 53.8943 118.4708 0081407 68.2645 292.7207 2.00558015103055' - .format(YY, (YY // 10 + YY - 7 + 5) % 10)), + '1 93054A 07266.92814765 .00000062 00000-0 10000-3 0 2895, ' + '2 22779 53.8943 118.4708 0081407 68.2645 292.7207 2.00558015103055'), 'Sunny, special', 'Slinky, star', 'xephem star, Sadr~20:22:13.7|2.43~40:15:24|-0.93~2.23~2000~0', From bc28821ca856fe5f3ce567038b93a9d760a9b86c Mon Sep 17 00:00:00 2001 From: Ludwig Schwardt Date: Tue, 25 Aug 2020 12:25:53 +0200 Subject: [PATCH 096/122] Standardise on a single location in body tests For some reason TLEs had their own location. --- katpoint/test/test_body.py | 24 +++++++++++------------- 1 file changed, 11 insertions(+), 13 deletions(-) diff --git a/katpoint/test/test_body.py b/katpoint/test/test_body.py index dfd8b0c..d476a6c 100644 --- a/katpoint/test/test_body.py +++ b/katpoint/test/test_body.py @@ -50,13 +50,12 @@ def _get_fixed_body(ra_str, dec_str): TLE_TS = '2019-09-23 07:45:36.000' TLE_AZ = '280:32:28.1892d' # 1. 280:32:28.6053 Skyfield (0.43" error, was 0.23" with WGS84) -# 2. 280:32:29.675 Astropy 4.0.1 + PyOrbital for TEME (1.82" error) +# 2. 280:32:29.675 Astropy 4.0.1 + PyOrbital for TEME (1.61" error) # 3. 280:32:07.2 PyEphem (37" error) -TLE_EL = '-54:06:49.3936d' -# 1. -54:06:49.0358 Skyfield -# 2. -54:06:50.7456 Astropy 4.0.1 + PyOrbital for TEME -# 3. -54:06:14.4 PyEphem -TLE_LOCATION = EarthLocation(lat=10.0, lon=80.0, height=4200.0) +TLE_EL = '-54:06:33.1950d' +# 1. -54:06:32.8374 Skyfield +# 2. -54:06:34.5473 Astropy 4.0.1 + PyOrbital for TEME +# 3. -54:05:58.2 PyEphem LOCATION = EarthLocation(lat=10.0, lon=80.0, height=0.0) @@ -83,10 +82,9 @@ def _get_fixed_body(ra_str, dec_str): def test_compute(body, date_str, ra_str, dec_str, az_str, el_str, tol): """Test compute method""" obstime = Time(date_str) - location = TLE_LOCATION if isinstance(body, EarthSatelliteBody) else LOCATION - radec = body.compute(ICRS(), obstime, location) + radec = body.compute(ICRS(), obstime, LOCATION) check_separation(radec, ra_str, dec_str, tol) - altaz = body.compute(AltAz(obstime=obstime, location=location), obstime, location) + altaz = body.compute(AltAz(obstime=obstime, location=LOCATION), obstime, LOCATION) check_separation(altaz, az_str, el_str, tol) @@ -94,16 +92,16 @@ def test_compute(body, date_str, ra_str, dec_str, az_str, el_str, tol): def test_earth_satellite_vs_skyfield(): ts = load.timescale() satellite = EarthSatellite(TLE_LINE1, TLE_LINE2, TLE_NAME, ts) - antenna = Topos(latitude_degrees=TLE_LOCATION.lat.deg, - longitude_degrees=TLE_LOCATION.lon.deg, - elevation_m=TLE_LOCATION.height.value) + antenna = Topos(latitude_degrees=LOCATION.lat.deg, + longitude_degrees=LOCATION.lon.deg, + elevation_m=LOCATION.height.value) obstime = Time(TLE_TS) t = ts.from_astropy(obstime) towards_sat = (satellite - antenna).at(t) alt, az, distance = towards_sat.altaz() altaz = AltAz(alt=Latitude(alt.radians, unit=u.rad), az=Longitude(az.radians, unit=u.rad), - obstime=obstime, location=TLE_LOCATION) + obstime=obstime, location=LOCATION) check_separation(altaz, TLE_AZ, TLE_EL, 0.5 * u.arcsec) From 3fe247993bdc209ab5c37a1f4cf3acbba0baefd4 Mon Sep 17 00:00:00 2001 From: Ludwig Schwardt Date: Tue, 25 Aug 2020 15:53:33 +0000 Subject: [PATCH 097/122] Apply 2 suggestion(s) to 1 file(s) --- katpoint/body.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/katpoint/body.py b/katpoint/body.py index 556890e..6c1c1ee 100644 --- a/katpoint/body.py +++ b/katpoint/body.py @@ -285,7 +285,7 @@ def from_edb(cls, line): 0, # satnum: Satellite number is not stored by XEphem, so pick an unused one sgp4_epoch, # epoch drag_coef, # bstar - (orbit_decay * u.cycle / u.day ** 2).to(u.rad / u.minute ** 2).value, # ndot + (orbit_decay * u.cycle / u.day ** 2).to_value(u.rad / u.minute ** 2), # ndot 0.0, # nddot (not used by SGP4) eccentricity, # ecco (arg_perigee * u.deg).to(u.rad).value, # argpo @@ -324,7 +324,7 @@ def to_edb(self): # The TLE is considered valid until the satellite period changes by more # than 1%, but never for more than 100 days either side of the epoch. # The mean motion is revs/day while decay is (revs/day)/day. - stable_days = np.clip(0.01 * mean_motion / abs(orbit_decay), None, 100) + stable_days = np.minimum(0.01 * mean_motion / abs(orbit_decay), 100) epoch_start = _time_to_edb(epoch - stable_days) # startok epoch_end = _time_to_edb(epoch + stable_days) # endok valid_range = f'|{epoch_start}|{epoch_end}' From fd4fdfc7635a89e9dbde13a679e785d97448b402 Mon Sep 17 00:00:00 2001 From: Ludwig Schwardt Date: Tue, 25 Aug 2020 17:26:44 +0200 Subject: [PATCH 098/122] Rework TLE epoch checks to use Astropy units Add tests that catch the log messages to ensure that the TLE checks are done for both near-Earth and deep-space satellites. Add a near-Earth Iridium satellite TLE to the unit test. Even fix a bug: the satellite name was stuck at the last one in the list - should have been `target.name`. --- katpoint/catalogue.py | 37 ++++++++++++++++++--------------- katpoint/test/test_catalogue.py | 17 ++++++++++----- 2 files changed, 32 insertions(+), 22 deletions(-) diff --git a/katpoint/catalogue.py b/katpoint/catalogue.py index 2ce9aeb..3e65e7e 100644 --- a/katpoint/catalogue.py +++ b/katpoint/catalogue.py @@ -500,33 +500,36 @@ def add_tle(self, lines, tags=None): if len(tle) > 0: logger.warning('Did not receive a multiple of three lines when constructing TLEs') - # Check TLE epochs and warn if some are too far in past or future, which would make TLE inaccurate right now - max_epoch_diff_days, num_outdated, worst = 0, 0, None + # Check TLE epochs and warn if some are too far in past or future, + # which would make TLE inaccurate right now + max_epoch_age = 0 * u.day + num_outdated = 0 + worst = None for target in targets: # Use orbital period to distinguish near-earth and deep-space objects # (which have different accuracies) mean_motion = target.body.satellite.no_kozai * u.rad / u.minute orbital_period = 1 * u.cycle / mean_motion - now = Time.now() - epoch_diff_days = np.abs(now - target.body.epoch).jd - direction = 'past' if target.body.epoch < now else 'future' + epoch_age = Time.now() - target.body.epoch + direction = 'past' if epoch_age > 0 else 'future' + epoch_age = abs(epoch_age) # Near-earth models should be good for about a week (conservative estimate) - if orbital_period < 225 * u.minute and epoch_diff_days > 7: + if orbital_period < 225 * u.minute and epoch_age > 7 * u.day: num_outdated += 1 - if epoch_diff_days > max_epoch_diff_days: - worst = "Worst case: TLE epoch for '%s' is %d days in %s, should be <= 7 for near-earth model" % \ - (name, epoch_diff_days, direction) - max_epoch_diff_days = epoch_diff_days + if epoch_age > max_epoch_age: + worst = (f"Worst case: TLE epoch for '{target.name}' is {epoch_age.jd:.0f} " + f"days in the {direction}, should be <= 7 for near-Earth model") + max_epoch_age = epoch_age # Deep-space models are more accurate (three weeks for a conservative estimate) - if orbital_period >= 225 * u.minute and epoch_diff_days > 21: + if orbital_period >= 225 * u.minute and epoch_age > 21 * u.day: num_outdated += 1 - if epoch_diff_days > max_epoch_diff_days: - worst = "Worst case: TLE epoch for '%s' is %d days in %s, should be <= 21 for deep-space model" % \ - (name, epoch_diff_days, direction) - max_epoch_diff_days = epoch_diff_days + if epoch_age > max_epoch_age: + worst = (f"Worst case: TLE epoch for '{target.name}' is {epoch_age.jd:.0f} " + f"days in the {direction}, should be <= 21 for deep-space model") + max_epoch_age = epoch_age if num_outdated > 0: - logger.warning('%d of %d TLE set(s) are outdated, probably making them inaccurate for use right now', - num_outdated, len(targets)) + logger.warning('%d of %d TLE set(s) are outdated, probably making them inaccurate ' + 'for use right now', num_outdated, len(targets)) logger.warning(worst) self.add(targets, tags) diff --git a/katpoint/test/test_catalogue.py b/katpoint/test/test_catalogue.py index 97a886d..e84221a 100644 --- a/katpoint/test/test_catalogue.py +++ b/katpoint/test/test_catalogue.py @@ -76,7 +76,7 @@ def test_catalogue_same_name(): assert len(cat) == len(cat.targets) == len(cat.lookup) == 0, 'Catalogue not empty' -def test_construct_catalogue(): +def test_construct_catalogue(caplog): """Test construction of catalogues.""" cat = katpoint.Catalogue(add_specials=True, add_stars=True, antenna=ANTENNA) num_targets_original = len(cat) @@ -102,17 +102,24 @@ def test_construct_catalogue(): test_target = cat.targets[-1] assert test_target.description == cat[test_target.name].description, 'Lookup failed' assert cat['Non-existent'] is None, 'Lookup of non-existent target failed' - tle_lines = ['# Comment ignored\n', + tle_lines = ['# Near-Earth object (comment ignored)\n', + 'IRIDIUM 7 [-] \n', + '1 24793U 97020B 19215.43137162 .00000054 00000-0 12282-4 0 9996\n', + '2 24793 86.3987 354.1155 0002085 89.4941 270.6494 14.34287341164608\n', + '# Deep-space object\n', 'GPS BIIA-21 (PRN 09) \n', '1 22700U 93042A 07266.32333151 .00000012 00000-0 10000-3 0 8054\n', - '2 22700 55.4408 61.3790 0191986 78.1802 283.9935 2.00561720104282\n'] + '2 22700 55.4408 61.3790 0191986 78.1802 283.9935 2.00561720104282\n', + ] cat.add_tle(tle_lines, 'tle') + assert "2 of 2 TLE set(s) are outdated" in caplog.text, 'TLE epoch checks failed' + assert "deep-space" in caplog.text, 'Worst TLE epoch should be deep-space GPS satellite' edb_lines = ['# Comment ignored\n', 'HIC 13847,f|S|A4,2:58:16.03,-40:18:17.1,2.906,2000,\n'] cat.add_edb(edb_lines, 'edb') - assert len(cat.targets) == num_targets + 2, 'Number of targets incorrect' + assert len(cat.targets) == num_targets + 3, 'Number of targets incorrect' cat.remove(cat.targets[-1].name) - assert len(cat.targets) == num_targets + 1, 'Number of targets incorrect' + assert len(cat.targets) == num_targets + 2, 'Number of targets incorrect' closest_target, dist = cat.closest_to(test_target) assert closest_target.description == test_target.description, 'Closest target incorrect' assert_allclose(dist, 0.0, rtol=0.0, atol=0.5e-5, From 32b59fdb70f30645918afe69f50d70813859906c Mon Sep 17 00:00:00 2001 From: Ludwig Schwardt Date: Tue, 25 Aug 2020 18:30:13 +0200 Subject: [PATCH 099/122] MR fixes - Force EDB timestamps to be UTC. - Use `.to_value()` instead of `.to().value`. - Line up comments describing EDB variables. - Document sat.revnum backdoor some more. - Document origin of jplephem test requirement. - Chain up ValueErrors for bad Target description strings. --- katpoint/body.py | 51 ++++++++++++++++++++++--------------------- katpoint/target.py | 16 ++++++++------ test-requirements.txt | 2 +- 3 files changed, 36 insertions(+), 33 deletions(-) diff --git a/katpoint/body.py b/katpoint/body.py index 6c1c1ee..dcd86bd 100644 --- a/katpoint/body.py +++ b/katpoint/body.py @@ -206,8 +206,8 @@ def _time_to_edb(t, high_precision=False): """Construct XEphem EDB epoch string from `Time` object.""" if not high_precision: # The XEphem startok/endok epochs are also single-precision MJDs - t = Time(np.float32(t.mjd), format='mjd') - dt = t.datetime + t = Time(np.float32(t.utc.mjd), format='mjd') + dt = t.utc.datetime second = dt.second + dt.microsecond / 1e6 minute = dt.minute + second / 60. hour = dt.hour + minute / 60. @@ -281,18 +281,18 @@ def from_edb(cls, line): sat = Satrec() sat.sgp4init( WGS72, # gravity model (TLEs are based on WGS72, therefore it is preferred to WGS84) - 'i', # 'a' = old AFSPC mode, 'i' = improved mode - 0, # satnum: Satellite number is not stored by XEphem, so pick an unused one - sgp4_epoch, # epoch - drag_coef, # bstar + 'i', # 'a' = old AFSPC mode, 'i' = improved mode + 0, # satnum: Satellite number is not stored by XEphem so pick an unused one + sgp4_epoch, # epoch + drag_coef, # bstar (orbit_decay * u.cycle / u.day ** 2).to_value(u.rad / u.minute ** 2), # ndot - 0.0, # nddot (not used by SGP4) - eccentricity, # ecco - (arg_perigee * u.deg).to(u.rad).value, # argpo - (inclination * u.deg).to(u.rad).value, # inclo - (mean_anomaly * u.deg).to(u.rad).value, # mo - (mean_motion * u.cycle / u.day).to(u.rad / u.minute).value, # no_kozai - (ra_asc_node * u.deg).to(u.rad).value, # nodeo + 0.0, # nddot (not used by SGP4) + eccentricity, # ecco + (arg_perigee * u.deg).to_value(u.rad), # argpo + (inclination * u.deg).to_value(u.rad), # inclo + (mean_anomaly * u.deg).to_value(u.rad), # mo + (mean_motion * u.cycle / u.day).to_value(u.rad / u.minute), # no_kozai + (ra_asc_node * u.deg).to_value(u.rad), # nodeo ) return cls(name, sat, int(orbit_number)) @@ -308,25 +308,26 @@ def to_edb(self): epoch = self.epoch # Extract orbital elements in XEphem units, and mostly single-precision. # The trailing comments are corresponding XEphem variable names. - inclination = np.float32((sat.inclo * u.rad).to(u.deg).value) # inc - ra_asc_node = np.float32((sat.nodeo * u.rad).to(u.deg).value) # raan - eccentricity = np.float32(sat.ecco) # e - arg_perigee = np.float32((sat.argpo * u.rad).to(u.deg).value) # ap - mean_anomaly = np.float32((sat.mo * u.rad).to(u.deg).value) # M + inclination = np.float32((sat.inclo * u.rad).to_value(u.deg)) # inc + ra_asc_node = np.float32((sat.nodeo * u.rad).to_value(u.deg)) # raan + eccentricity = np.float32(sat.ecco) # e + arg_perigee = np.float32((sat.argpo * u.rad).to_value(u.deg)) # ap + mean_anomaly = np.float32((sat.mo * u.rad).to_value(u.deg)) # M # The mean motion uses double precision due to "sensitive differencing operation" - mean_motion = (sat.no_kozai * u.rad / u.minute).to(u.cycle / u.day).value # n - orbit_decay = (sat.ndot * u.rad / u.minute ** 2).to(u.cycle / u.day ** 2).value # decay + mean_motion = (sat.no_kozai * u.rad / u.minute).to_value(u.cycle / u.day) # n + orbit_decay = (sat.ndot * u.rad / u.minute ** 2).to_value(u.cycle / u.day ** 2) # decay orbit_decay = np.float32(orbit_decay) - orbit_number = sat.revnum if sat.revnum else self.orbit_number # orbit - drag_coef = np.float32(sat.bstar) # drag - epoch_str = _time_to_edb(epoch, high_precision=True) # epoch + # XXX Satrec object only accepts revnum via twoline2rv but EDB needs it, so add a backdoor + orbit_number = sat.revnum if sat.revnum else self.orbit_number # orbit + drag_coef = np.float32(sat.bstar) # drag + epoch_str = _time_to_edb(epoch, high_precision=True) # epoch if abs(orbit_decay) > 0: # The TLE is considered valid until the satellite period changes by more # than 1%, but never for more than 100 days either side of the epoch. # The mean motion is revs/day while decay is (revs/day)/day. stable_days = np.minimum(0.01 * mean_motion / abs(orbit_decay), 100) - epoch_start = _time_to_edb(epoch - stable_days) # startok - epoch_end = _time_to_edb(epoch + stable_days) # endok + epoch_start = _time_to_edb(epoch - stable_days) # startok + epoch_end = _time_to_edb(epoch + stable_days) # endok valid_range = f'|{epoch_start}|{epoch_end}' else: valid_range = '' diff --git a/katpoint/target.py b/katpoint/target.py index 08e2040..9490e74 100644 --- a/katpoint/target.py +++ b/katpoint/target.py @@ -971,8 +971,9 @@ def construct_target_params(description): preferred_name = 'Unnamed Satellite' try: body = EarthSatelliteBody.from_tle(preferred_name, fields[2], fields[3]) - except ValueError: - raise ValueError("Target description '%s' contains malformed *tle* body" % description) + except ValueError as err: + raise ValueError(f"Target description '{description}' " + "contains malformed *tle* body") from err elif body_type == 'special': try: @@ -988,9 +989,9 @@ def construct_target_params(description): star_name = ' '.join([w.capitalize() for w in preferred_name.split()]) try: body = STARS[star_name] - except KeyError: - raise ValueError("Target description '%s' contains unknown *star* '%s'" - % (description, star_name)) + except KeyError as err: + raise ValueError(f"Target description '{description}' " + f"contains unknown *star* '{star_name}'") from err elif body_type == 'xephem': edb_string = fields[-1].replace('~', ',') @@ -1007,8 +1008,9 @@ def construct_target_params(description): aliases.append(extra_name) try: body = Body.from_edb(edb_string) - except ValueError: - raise ValueError("Target description '%s' contains malformed *xephem* body" % description) + except ValueError as err: + raise ValueError(f"Target description '{description}' " + "contains malformed *xephem* body") from err # Add xephem body type as an extra tag, right after the main 'xephem' tag edb_type = edb_string[edb_string.find(',') + 1] if edb_type == 'f': diff --git a/test-requirements.txt b/test-requirements.txt index 9063766..302e019 100644 --- a/test-requirements.txt +++ b/test-requirements.txt @@ -2,7 +2,7 @@ attrs # via pytest certifi # via skyfield coverage # via pytest-cov importlib-metadata # via pytest -jplephem==2.14 +jplephem==2.14 # via skyfield more-itertools # via pytest packaging # via pytest pluggy # via pytest From 0c0b64dbac80aab233c10f06511fc906d80d3bdc Mon Sep 17 00:00:00 2001 From: Ludwig Schwardt Date: Wed, 26 Aug 2020 13:28:50 +0200 Subject: [PATCH 100/122] More MR fixes Improve error reporting when parsing description strings. Add a reminder that `_time_to_edb` does not like leap seconds. Be explicit about using the UTC scale in TLE epoch. --- katpoint/body.py | 3 ++- katpoint/target.py | 6 +++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/katpoint/body.py b/katpoint/body.py index dcd86bd..2efb41f 100644 --- a/katpoint/body.py +++ b/katpoint/body.py @@ -204,6 +204,7 @@ def _edb_to_time(edb_epoch): def _time_to_edb(t, high_precision=False): """Construct XEphem EDB epoch string from `Time` object.""" + # The output of this function is undefined if `t` is within a leap second if not high_precision: # The XEphem startok/endok epochs are also single-precision MJDs t = Time(np.float32(t.utc.mjd), format='mjd') @@ -244,7 +245,7 @@ def __init__(self, name, satellite, orbit_number=0): @property def epoch(self): """The moment in time when the satellite model is true, as an Astropy `Time`.""" - return Time(self.satellite.jdsatepoch, self.satellite.jdsatepochF, format='jd') + return Time(self.satellite.jdsatepoch, self.satellite.jdsatepochF, scale='utc', format='jd') @classmethod def from_tle(cls, name, line1, line2): diff --git a/katpoint/target.py b/katpoint/target.py index 9490e74..b79afd7 100644 --- a/katpoint/target.py +++ b/katpoint/target.py @@ -973,7 +973,7 @@ def construct_target_params(description): body = EarthSatelliteBody.from_tle(preferred_name, fields[2], fields[3]) except ValueError as err: raise ValueError(f"Target description '{description}' " - "contains malformed *tle* body") from err + f"contains malformed *tle* body: {err}") from err elif body_type == 'special': try: @@ -991,7 +991,7 @@ def construct_target_params(description): body = STARS[star_name] except KeyError as err: raise ValueError(f"Target description '{description}' " - f"contains unknown *star* '{star_name}'") from err + f"contains unknown *star* '{star_name}'") from None elif body_type == 'xephem': edb_string = fields[-1].replace('~', ',') @@ -1010,7 +1010,7 @@ def construct_target_params(description): body = Body.from_edb(edb_string) except ValueError as err: raise ValueError(f"Target description '{description}' " - "contains malformed *xephem* body") from err + f"contains malformed *xephem* body: {err}") from err # Add xephem body type as an extra tag, right after the main 'xephem' tag edb_type = edb_string[edb_string.find(',') + 1] if edb_type == 'f': From ebefc31793e93a2ff5a866be01e3ee10947652b8 Mon Sep 17 00:00:00 2001 From: Ludwig Schwardt Date: Wed, 26 Aug 2020 16:36:04 +0200 Subject: [PATCH 101/122] Don't check EDB that thoroughly It's a deprecated format... --- katpoint/test/test_body.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/katpoint/test/test_body.py b/katpoint/test/test_body.py index d476a6c..36093ee 100644 --- a/katpoint/test/test_body.py +++ b/katpoint/test/test_body.py @@ -138,10 +138,6 @@ def test_earth_satellite(): body2 = Body.from_edb(xephem) assert isinstance(body2, EarthSatelliteBody) assert body2.to_edb() == xephem - _check_edb_E(body2.satellite, epoch_iso='2019-09-23 07:45:35.842', - inc=55.4408, raan=61.379002, e=0.0191986, ap=78.180199, M=283.9935, - n=2.0056172, decay=1.2e-07, drag=9.9999997e-05) - assert body2.orbit_number == 10428 # XXX This should eventually be sat.revnum def test_star(): From e7567c123c2c9fa914539dcab03b8f873d2e2d41 Mon Sep 17 00:00:00 2001 From: Ludwig Schwardt Date: Wed, 2 Sep 2020 22:43:22 +0200 Subject: [PATCH 102/122] Add tests for angle conversion routines Before we change these, add tests. I picked test_body.py because body.py will be their future home (and ephem_extra.py will disappear). The angle_from_hours routine fails when passed a decimal string. This should be in degrees and not hours. The existing code circumvents this by first attempting to convert a decimal string to a float in radians. --- katpoint/test/test_body.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/katpoint/test/test_body.py b/katpoint/test/test_body.py index 36093ee..695188d 100644 --- a/katpoint/test/test_body.py +++ b/katpoint/test/test_body.py @@ -27,6 +27,7 @@ from astropy.coordinates import SkyCoord, ICRS, AltAz from astropy.coordinates import EarthLocation, Latitude, Longitude +from katpoint.ephem_extra import angle_from_degrees, angle_from_hours from katpoint.body import Body, FixedBody, SolarSystemBody, EarthSatelliteBody from katpoint.test.helper import check_separation @@ -38,6 +39,20 @@ HAS_SKYFIELD = True +@pytest.mark.parametrize("angle, angle_deg", [('10:00:00', 10), ('10.0', 10), + ((10 * u.deg).to_value(u.rad), 10), + ('10d00m00s', 10)]) +def test_angle_from_degrees(angle, angle_deg): + assert angle_from_degrees(angle).deg == angle_deg + + +@pytest.mark.parametrize("angle, angle_hour", [('10:00:00', 10), ('150.0', 10), + ((150 * u.deg).to_value(u.rad), pytest.approx(10)), + ('10h00m00s', 10)]) +def test_angle_from_hours(angle, angle_hour): + assert angle_from_hours(angle).hour == angle_hour + + def _get_fixed_body(ra_str, dec_str): ra = Longitude(ra_str, unit=u.hour) dec = Latitude(dec_str, unit=u.deg) From d0df5493681ceac9b1ee480e3b2bdcc95c6fa19a Mon Sep 17 00:00:00 2001 From: Ludwig Schwardt Date: Wed, 2 Sep 2020 23:25:08 +0200 Subject: [PATCH 103/122] Improve angle conversion routines First attempt to construct an Astropy `Angle` directly and only fall back to default units if that fails due to missing units. This ensures that the angle can be a copy of an existing `Angle` object without coercing it to another unit. Another change is that decimal strings are always in degrees, while sexagesimal strings (and tuples) are either in degrees or hours, depending on the function. Have radians as the fallback unit because it is stricter (eg it won't accept tuples), and this means that ndarrays without units will be in radians too, just like scalar numbers. --- katpoint/ephem_extra.py | 30 ++++++++++++++++-------------- katpoint/test/test_body.py | 6 +++--- 2 files changed, 19 insertions(+), 17 deletions(-) diff --git a/katpoint/ephem_extra.py b/katpoint/ephem_extra.py index 2e79c7d..2a6c1a3 100644 --- a/katpoint/ephem_extra.py +++ b/katpoint/ephem_extra.py @@ -67,31 +67,33 @@ def _just_gimme_an_ascii_string(s): def angle_from_degrees(s): """Creates angle object from sexagesimal string in degrees or number in radians.""" try: - # Ephem expects a number or platform-appropriate string (i.e. Unicode on Py3) - if type(s) == str: - return Angle(s, unit=u.deg) - elif type(s) == tuple: + return Angle(s) + except u.UnitsError: + # Deal with user input + if isinstance(s, bytes): + s = s.decode(encoding='ascii') + # We now have a number, string or tuple without a unit + if isinstance(s, (str, tuple)): return Angle(s, unit=u.deg) else: return Angle(s, unit=u.rad) - except TypeError: - # If input is neither, assume that it really wants to be a string - return Angle(_just_gimme_an_ascii_string(s), unit=u.deg) def angle_from_hours(s): """Creates angle object from sexagesimal string in hours or number in radians.""" try: - # Ephem expects a number or platform-appropriate string (i.e. Unicode on Py3) - if type(s) == str: - return Angle(s, unit=u.hour) - elif type(s) == tuple: + return Angle(s) + except u.UnitsError: + # Deal with user input + if isinstance(s, bytes): + s = s.decode(encoding='ascii') + # We now have a number, string or tuple without a unit + if isinstance(s, str) and ':' in s or isinstance(s, tuple): return Angle(s, unit=u.hour) + if isinstance(s, str): + return Angle(s, unit=u.deg) else: return Angle(s, unit=u.rad) - except TypeError: - # If input is neither, assume that it really wants to be a string - return Angle(_just_gimme_an_ascii_string(s), unit=u.hour) def wrap_angle(angle, period=2.0 * np.pi): diff --git a/katpoint/test/test_body.py b/katpoint/test/test_body.py index 695188d..9385511 100644 --- a/katpoint/test/test_body.py +++ b/katpoint/test/test_body.py @@ -41,14 +41,14 @@ @pytest.mark.parametrize("angle, angle_deg", [('10:00:00', 10), ('10.0', 10), ((10 * u.deg).to_value(u.rad), 10), - ('10d00m00s', 10)]) + ('10d00m00s', 10), ((10, 0, 0), 10)]) def test_angle_from_degrees(angle, angle_deg): assert angle_from_degrees(angle).deg == angle_deg -@pytest.mark.parametrize("angle, angle_hour", [('10:00:00', 10), ('150.0', 10), +@pytest.mark.parametrize("angle, angle_hour", [('10:00:00', 10), ('150.0', pytest.approx(10)), ((150 * u.deg).to_value(u.rad), pytest.approx(10)), - ('10h00m00s', 10)]) + ('10h00m00s', 10), ((10, 0, 0), 10)]) def test_angle_from_hours(angle, angle_hour): assert angle_from_hours(angle).hour == angle_hour From 8c10f4178b445847bce3c2cc6ae1112deb22286e Mon Sep 17 00:00:00 2001 From: Ludwig Schwardt Date: Thu, 3 Sep 2020 10:12:25 +0200 Subject: [PATCH 104/122] Consolidate angle conversion routines The `angle_from_degrees` and `angle_from_hours` functions only differ in how they handle sexagesimal input, so make that a parameter of a common `to_angle` function instead. Add a docstring. Fix up the use of this function in Target. It is not necessary to cast the angle to a float in radians first. Also promote the Angle to a Latitude or Longitude where possible. --- katpoint/body.py | 4 ++-- katpoint/ephem_extra.py | 39 ++++++++++++++++++++++---------------- katpoint/pointing.py | 11 +++++------ katpoint/target.py | 20 ++++++------------- katpoint/test/test_body.py | 6 +++--- 5 files changed, 39 insertions(+), 41 deletions(-) diff --git a/katpoint/body.py b/katpoint/body.py index 2efb41f..a02b1dc 100644 --- a/katpoint/body.py +++ b/katpoint/body.py @@ -28,7 +28,7 @@ from sgp4.model import Satrec as SatrecPython from sgp4.exporter import export_tle -from .ephem_extra import angle_from_degrees +from .ephem_extra import to_angle class Body: @@ -371,7 +371,7 @@ class StationaryBody(Body): """ def __init__(self, az, el, name=None): - self.coord = AltAz(az=angle_from_degrees(az), alt=angle_from_degrees(el)) + self.coord = AltAz(az=Longitude(to_angle(az)), alt=Latitude(to_angle(el))) if not name: name = "Az: {} El: {}".format(self.coord.az.to_string(sep=':', unit=u.deg), self.coord.alt.to_string(sep=':', unit=u.deg)) diff --git a/katpoint/ephem_extra.py b/katpoint/ephem_extra.py index 2a6c1a3..60b5b96 100644 --- a/katpoint/ephem_extra.py +++ b/katpoint/ephem_extra.py @@ -64,23 +64,30 @@ def _just_gimme_an_ascii_string(s): return str(s) -def angle_from_degrees(s): - """Creates angle object from sexagesimal string in degrees or number in radians.""" - try: - return Angle(s) - except u.UnitsError: - # Deal with user input - if isinstance(s, bytes): - s = s.decode(encoding='ascii') - # We now have a number, string or tuple without a unit - if isinstance(s, (str, tuple)): - return Angle(s, unit=u.deg) - else: - return Angle(s, unit=u.rad) +def to_angle(s, sexagesimal=u.deg): + """Construct an `Angle` with default units. + + This creates an :class:`~astropy.coordinates.Angle` with the following + default units: + - A number is in radians. + - A decimal string ('123.4') is in degrees. + - A sexagesimal string ('12:34:56.7') or tuple has unit `sexagesimal`. -def angle_from_hours(s): - """Creates angle object from sexagesimal string in hours or number in radians.""" + In addition, bytes are decoded to ASCII strings to normalize user inputs. + + Parameters + ---------- + s : :class:`~astropy.coordinates.Angle` or equivalent + Anything accepted by `Angle` and also unitless strings, numbers, tuples + sexagesimal : :class:`~astropy.units.UnitBase` or str, optional + The unit applied to sexagesimal strings and tuples + + Returns + ------- + angle : :class:`~astropy.coordinates.Angle` + Astropy `Angle` + """ try: return Angle(s) except u.UnitsError: @@ -89,7 +96,7 @@ def angle_from_hours(s): s = s.decode(encoding='ascii') # We now have a number, string or tuple without a unit if isinstance(s, str) and ':' in s or isinstance(s, tuple): - return Angle(s, unit=u.hour) + return Angle(s, unit=sexagesimal) if isinstance(s, str): return Angle(s, unit=u.deg) else: diff --git a/katpoint/pointing.py b/katpoint/pointing.py index a967e54..90fa130 100644 --- a/katpoint/pointing.py +++ b/katpoint/pointing.py @@ -25,7 +25,7 @@ from astropy import units from .model import Parameter, Model -from .ephem_extra import rad2deg, deg2rad, angle_from_degrees +from .ephem_extra import rad2deg, deg2rad, to_angle logger = logging.getLogger(__name__) @@ -55,17 +55,16 @@ class PointingModel(Model): def __init__(self, model=None): # There are two main types of parameter: angles and scale factors def angle_to_string(a): - return angle_from_degrees(a).to_string(sep=':', unit=units.deg) if a != 0 else '0' + return to_angle(a).to_string(sep=':', unit=units.deg) if a != 0 else '0' def angle_param(name, doc): """Create angle-valued parameter.""" - return Parameter(name, 'deg', doc, from_str=angle_from_degrees, - to_str=angle_to_string) + return Parameter(name, 'deg', doc, from_str=to_angle, to_str=angle_to_string) def scale_param(name, doc): """Create scale-valued parameter.""" - return Parameter(name, '', doc, - to_str=lambda s: ('%.9g' % (s,)) if s else '0') + return Parameter(name, '', doc, to_str=lambda s: ('%.9g' % (s,)) if s else '0') + # Instantiate the relevant model parameters and register with base class params = [] params.append(angle_param('P1', 'az offset = encoder bias - tilt around [tpoint -IA]')) diff --git a/katpoint/target.py b/katpoint/target.py index b79afd7..4a55bfd 100644 --- a/katpoint/target.py +++ b/katpoint/target.py @@ -25,7 +25,7 @@ from .timestamp import Timestamp, delta_seconds from .flux import FluxDensityModel -from .ephem_extra import (is_iterable, lightspeed, deg2rad, angle_from_degrees, angle_from_hours) +from .ephem_extra import is_iterable, lightspeed, to_angle from .conversion import azel_to_enu from .projection import sphere_to_plane, sphere_to_ortho, plane_to_sphere from .body import Body, FixedBody, SolarSystemBody, EarthSatelliteBody, StationaryBody, NullBody @@ -937,11 +937,8 @@ def construct_target_params(description): if len(fields) < 4: raise ValueError("Target description '%s' contains *radec* body with no (ra, dec) coordinates" % description) - try: - ra = deg2rad(float(fields[2])) - except ValueError: - ra = fields[2] - ra, dec = angle_from_hours(ra), angle_from_degrees(fields[3]) + ra = Longitude(to_angle(fields[2], sexagesimal=u.hour)) + dec = Latitude(to_angle(fields[3])) if not preferred_name: preferred_name = "Ra: %s Dec: %s" % (ra, dec) # Extract epoch info from tags @@ -989,7 +986,7 @@ def construct_target_params(description): star_name = ' '.join([w.capitalize() for w in preferred_name.split()]) try: body = STARS[star_name] - except KeyError as err: + except KeyError: raise ValueError(f"Target description '{description}' " f"contains unknown *star* '{star_name}'") from None @@ -1080,13 +1077,8 @@ def construct_radec_target(ra, dec): target : :class:`Target` object Constructed target object """ - # First try to interpret the string as decimal degrees - if isinstance(ra, str): - try: - ra = deg2rad(float(ra)) - except ValueError: - pass - ra, dec = angle_from_hours(ra), angle_from_degrees(dec) + ra = Longitude(to_angle(ra, sexagesimal=u.hour)) + dec = Latitude(to_angle(dec)) name = "Ra: %s Dec: %s" % (ra, dec) body = FixedBody(name, SkyCoord(ra=ra, dec=dec, frame=ICRS)) return Target(body, 'radec') diff --git a/katpoint/test/test_body.py b/katpoint/test/test_body.py index 9385511..4ec9088 100644 --- a/katpoint/test/test_body.py +++ b/katpoint/test/test_body.py @@ -27,7 +27,7 @@ from astropy.coordinates import SkyCoord, ICRS, AltAz from astropy.coordinates import EarthLocation, Latitude, Longitude -from katpoint.ephem_extra import angle_from_degrees, angle_from_hours +from katpoint.ephem_extra import to_angle from katpoint.body import Body, FixedBody, SolarSystemBody, EarthSatelliteBody from katpoint.test.helper import check_separation @@ -43,14 +43,14 @@ ((10 * u.deg).to_value(u.rad), 10), ('10d00m00s', 10), ((10, 0, 0), 10)]) def test_angle_from_degrees(angle, angle_deg): - assert angle_from_degrees(angle).deg == angle_deg + assert to_angle(angle, sexagesimal=u.deg).deg == angle_deg @pytest.mark.parametrize("angle, angle_hour", [('10:00:00', 10), ('150.0', pytest.approx(10)), ((150 * u.deg).to_value(u.rad), pytest.approx(10)), ('10h00m00s', 10), ((10, 0, 0), 10)]) def test_angle_from_hours(angle, angle_hour): - assert angle_from_hours(angle).hour == angle_hour + assert to_angle(angle, sexagesimal=u.hour).hour == angle_hour def _get_fixed_body(ra_str, dec_str): From 82710a07465c0f39af503a4c5460b03d919dc87d Mon Sep 17 00:00:00 2001 From: Ludwig Schwardt Date: Thu, 3 Sep 2020 10:22:28 +0200 Subject: [PATCH 105/122] Move to_angle to body.py This is in anticipation of the demise of ephem_extra. The to_angle function is mostly used to build Bodies from descriptions, so it seems like a natural fit. --- katpoint/body.py | 41 ++++++++++++++++++++++++++++++++++++-- katpoint/ephem_extra.py | 39 ------------------------------------ katpoint/pointing.py | 3 ++- katpoint/target.py | 5 +++-- katpoint/test/test_body.py | 3 +-- 5 files changed, 45 insertions(+), 46 deletions(-) diff --git a/katpoint/body.py b/katpoint/body.py index a02b1dc..c2223b6 100644 --- a/katpoint/body.py +++ b/katpoint/body.py @@ -21,14 +21,51 @@ import numpy as np import astropy.units as u from astropy.time import Time -from astropy.coordinates import ICRS, AltAz, Latitude, Longitude, SkyCoord +from astropy.coordinates import SkyCoord, ICRS, AltAz, Latitude, Longitude, Angle from astropy.coordinates import solar_system_ephemeris, get_body from astropy.coordinates import TEME, CartesianDifferential, CartesianRepresentation from sgp4.api import Satrec, WGS72 from sgp4.model import Satrec as SatrecPython from sgp4.exporter import export_tle -from .ephem_extra import to_angle + +def to_angle(s, sexagesimal=u.deg): + """Construct an `Angle` with default units. + + This creates an :class:`~astropy.coordinates.Angle` with the following + default units: + + - A number is in radians. + - A decimal string ('123.4') is in degrees. + - A sexagesimal string ('12:34:56.7') or tuple has unit `sexagesimal`. + + In addition, bytes are decoded to ASCII strings to normalize user inputs. + + Parameters + ---------- + s : :class:`~astropy.coordinates.Angle` or equivalent + Anything accepted by `Angle` and also unitless strings, numbers, tuples + sexagesimal : :class:`~astropy.units.UnitBase` or str, optional + The unit applied to sexagesimal strings and tuples + + Returns + ------- + angle : :class:`~astropy.coordinates.Angle` + Astropy `Angle` + """ + try: + return Angle(s) + except u.UnitsError: + # Deal with user input + if isinstance(s, bytes): + s = s.decode(encoding='ascii') + # We now have a number, string or tuple without a unit + if isinstance(s, str) and ':' in s or isinstance(s, tuple): + return Angle(s, unit=sexagesimal) + if isinstance(s, str): + return Angle(s, unit=u.deg) + else: + return Angle(s, unit=u.rad) class Body: diff --git a/katpoint/ephem_extra.py b/katpoint/ephem_extra.py index 60b5b96..612e618 100644 --- a/katpoint/ephem_extra.py +++ b/katpoint/ephem_extra.py @@ -64,45 +64,6 @@ def _just_gimme_an_ascii_string(s): return str(s) -def to_angle(s, sexagesimal=u.deg): - """Construct an `Angle` with default units. - - This creates an :class:`~astropy.coordinates.Angle` with the following - default units: - - - A number is in radians. - - A decimal string ('123.4') is in degrees. - - A sexagesimal string ('12:34:56.7') or tuple has unit `sexagesimal`. - - In addition, bytes are decoded to ASCII strings to normalize user inputs. - - Parameters - ---------- - s : :class:`~astropy.coordinates.Angle` or equivalent - Anything accepted by `Angle` and also unitless strings, numbers, tuples - sexagesimal : :class:`~astropy.units.UnitBase` or str, optional - The unit applied to sexagesimal strings and tuples - - Returns - ------- - angle : :class:`~astropy.coordinates.Angle` - Astropy `Angle` - """ - try: - return Angle(s) - except u.UnitsError: - # Deal with user input - if isinstance(s, bytes): - s = s.decode(encoding='ascii') - # We now have a number, string or tuple without a unit - if isinstance(s, str) and ':' in s or isinstance(s, tuple): - return Angle(s, unit=sexagesimal) - if isinstance(s, str): - return Angle(s, unit=u.deg) - else: - return Angle(s, unit=u.rad) - - def wrap_angle(angle, period=2.0 * np.pi): """Wrap angle into interval centred on zero. diff --git a/katpoint/pointing.py b/katpoint/pointing.py index 90fa130..9d3bc69 100644 --- a/katpoint/pointing.py +++ b/katpoint/pointing.py @@ -25,7 +25,8 @@ from astropy import units from .model import Parameter, Model -from .ephem_extra import rad2deg, deg2rad, to_angle +from .body import to_angle +from .ephem_extra import rad2deg, deg2rad logger = logging.getLogger(__name__) diff --git a/katpoint/target.py b/katpoint/target.py index 4a55bfd..243e29d 100644 --- a/katpoint/target.py +++ b/katpoint/target.py @@ -25,10 +25,11 @@ from .timestamp import Timestamp, delta_seconds from .flux import FluxDensityModel -from .ephem_extra import is_iterable, lightspeed, to_angle +from .ephem_extra import is_iterable, lightspeed from .conversion import azel_to_enu from .projection import sphere_to_plane, sphere_to_ortho, plane_to_sphere -from .body import Body, FixedBody, SolarSystemBody, EarthSatelliteBody, StationaryBody, NullBody +from .body import (Body, FixedBody, SolarSystemBody, EarthSatelliteBody, + StationaryBody, NullBody, to_angle) from .stars import STARS diff --git a/katpoint/test/test_body.py b/katpoint/test/test_body.py index 4ec9088..7e4a3a2 100644 --- a/katpoint/test/test_body.py +++ b/katpoint/test/test_body.py @@ -27,8 +27,7 @@ from astropy.coordinates import SkyCoord, ICRS, AltAz from astropy.coordinates import EarthLocation, Latitude, Longitude -from katpoint.ephem_extra import to_angle -from katpoint.body import Body, FixedBody, SolarSystemBody, EarthSatelliteBody +from katpoint.body import Body, FixedBody, SolarSystemBody, EarthSatelliteBody, to_angle from katpoint.test.helper import check_separation try: From 68697987deb1f53db843bda5d592328d9f82e706 Mon Sep 17 00:00:00 2001 From: Ludwig Schwardt Date: Thu, 3 Sep 2020 10:27:54 +0200 Subject: [PATCH 106/122] Get rid of _just_gimme_an_ascii_string This is a leftover of the Python 2 era and its Unicode malarky. In Python 3 JSON just does the right thing... No need to "remedy" it. The other use of it (to sanitise angle strings) is now taken care of by explicit bytes decoding. --- katpoint/delay.py | 9 ++++----- katpoint/ephem_extra.py | 20 -------------------- 2 files changed, 4 insertions(+), 25 deletions(-) diff --git a/katpoint/delay.py b/katpoint/delay.py index 79d224b..c8c63ae 100644 --- a/katpoint/delay.py +++ b/katpoint/delay.py @@ -28,7 +28,7 @@ from .model import Parameter, Model from .conversion import azel_to_enu -from .ephem_extra import lightspeed, is_iterable, _just_gimme_an_ascii_string +from .ephem_extra import lightspeed, is_iterable from .target import construct_radec_target @@ -140,8 +140,7 @@ def __init__(self, ants, ref_ant=None, sky_centre_freq=0.0, extra_delay=None): except ValueError: raise ValueError("Trying to construct DelayCorrection with an " "invalid description string %r" % (ants,)) - # JSON only returns Unicode, even on Python 2... Remedy this. - ref_ant_str = _just_gimme_an_ascii_string(descr['ref_ant']) + ref_ant_str = descr['ref_ant'] # Antenna needs DelayModel which also lives in this module... # This is messy but avoids a circular dependency and having to # split this file into two small bits. @@ -152,8 +151,8 @@ def __init__(self, ants, ref_ant=None, sky_centre_freq=0.0, extra_delay=None): ant_models = {} for ant_name, ant_model_str in descr['ant_models'].items(): ant_model = DelayModel() - ant_model.fromstring(_just_gimme_an_ascii_string(ant_model_str)) - ant_models[_just_gimme_an_ascii_string(ant_name)] = ant_model + ant_model.fromstring(ant_model_str) + ant_models[ant_name] = ant_model else: # `ants` is a sequence of Antennas - verify and extract delay models if ref_ant is None: diff --git a/katpoint/ephem_extra.py b/katpoint/ephem_extra.py index 612e618..5838199 100644 --- a/katpoint/ephem_extra.py +++ b/katpoint/ephem_extra.py @@ -44,26 +44,6 @@ def deg2rad(x): return x * (np.pi / 180.0) -def _just_gimme_an_ascii_string(s): - """Converts encoded/decoded string to a platform-appropriate ASCII string. - - On Python 2 this encodes Unicode strings to normal ASCII strings, while - normal strings are left unchanged. On Python 3 this decodes bytes to - Unicode strings via the ASCII encoding, while Unicode strings are left - unchanged (and might still contain non-ASCII characters!). - - Raises - ------ - UnicodeEncodeError, UnicodeDecodeError - If the conversion fails due to the presence of non-ASCII characters - """ - if isinstance(s, bytes) and not isinstance(s, str): - # Only encoded bytes on Python 3 will end up here - return str(s, encoding='ascii') - else: - return str(s) - - def wrap_angle(angle, period=2.0 * np.pi): """Wrap angle into interval centred on zero. From 216cb16d72a895e0938d013ed85f221e7706ca9c Mon Sep 17 00:00:00 2001 From: Ludwig Schwardt Date: Thu, 3 Sep 2020 12:13:22 +0200 Subject: [PATCH 107/122] Get rid of katpoint's rad2deg and deg2rad These are provided by NumPy these days, at least since version 1.3 in April 2009, which is about the same time katpoint started. :-) I had mistakenly thought that katpoint's rad2deg and deg2rad can round-trip better than NumPy's version (see commit 12c13f11) but that is not the case, so there is no reason to keep them around. This changes the public API but it should be obvious how to fix the resulting breakages (replace "katpoint." with "np."). --- katpoint/__init__.py | 2 +- katpoint/ephem_extra.py | 12 ------------ katpoint/pointing.py | 9 ++++----- katpoint/refraction.py | 14 +++++++------- katpoint/test/test_pointing.py | 6 +++--- katpoint/test/test_refraction.py | 2 +- katpoint/test/test_target.py | 2 +- scripts/bae_optical_pointing_sources.py | 4 ++-- scripts/kuehr1Jy_sources.py | 2 +- scripts/tabara_sources.py | 2 +- 10 files changed, 21 insertions(+), 34 deletions(-) diff --git a/katpoint/__init__.py b/katpoint/__init__.py index bd6b6c7..faf3d4c 100644 --- a/katpoint/__init__.py +++ b/katpoint/__init__.py @@ -34,7 +34,7 @@ from .timestamp import Timestamp from .flux import FluxDensityModel, FluxError from .catalogue import Catalogue, specials -from .ephem_extra import lightspeed, rad2deg, deg2rad, wrap_angle, is_iterable +from .ephem_extra import lightspeed, wrap_angle, is_iterable from .conversion import (lla_to_ecef, ecef_to_lla, enu_to_ecef, ecef_to_enu, azel_to_enu, enu_to_azel, hadec_to_enu, enu_to_xyz) from .projection import sphere_to_plane, plane_to_sphere diff --git a/katpoint/ephem_extra.py b/katpoint/ephem_extra.py index 5838199..bd95fb8 100644 --- a/katpoint/ephem_extra.py +++ b/katpoint/ephem_extra.py @@ -17,8 +17,6 @@ """Enhancements to PyEphem.""" import numpy as np -import astropy.units as u -from astropy.coordinates import Angle # -------------------------------------------------------------------------------------------------- # --- Helper functions @@ -34,16 +32,6 @@ def is_iterable(x): not (getattr(x, 'shape', None) == ()) -def rad2deg(x): - """Converts radians to degrees (also works for arrays).""" - return x * (180.0 / np.pi) - - -def deg2rad(x): - """Converts degrees to radians (also works for arrays).""" - return x * (np.pi / 180.0) - - def wrap_angle(angle, period=2.0 * np.pi): """Wrap angle into interval centred on zero. diff --git a/katpoint/pointing.py b/katpoint/pointing.py index 9d3bc69..5551819 100644 --- a/katpoint/pointing.py +++ b/katpoint/pointing.py @@ -26,7 +26,6 @@ from .model import Parameter, Model from .body import to_angle -from .ephem_extra import rad2deg, deg2rad logger = logging.getLogger(__name__) @@ -140,7 +139,7 @@ def offset(self, az, el): sin_el, cos_el, sin_8el, cos_8el = np.sin(el), np.cos(el), np.sin(8 * el), np.cos(8 * el) # Avoid singularity at zenith by keeping cos(el) away from zero - this only affects az offset # Preserve the sign of cos(el), as this will allow for correct antenna plunging - sec_el = np.sign(cos_el) / np.clip(np.abs(cos_el), deg2rad(6. / 60.), 1.0) + sec_el = np.sign(cos_el) / np.clip(np.abs(cos_el), np.radians(6. / 60.), 1.0) tan_el = sin_el * sec_el # Obtain pointing correction using full VLBI model for alt-az mount (no P2 or P10 allowed!) @@ -198,7 +197,7 @@ def _jacobian(self, az, el): sin_el, cos_el, sin_8el, cos_8el = np.sin(el), np.cos(el), np.sin(8 * el), np.cos(8 * el) # Avoid singularity at zenith by keeping cos(el) away from zero - this only affects az offset # Preserve the sign of cos(el), as this will allow for correct antenna plunging - sec_el = np.sign(cos_el) / np.clip(np.abs(cos_el), deg2rad(6. / 60.), 1.0) + sec_el = np.sign(cos_el) / np.clip(np.abs(cos_el), np.radians(6. / 60.), 1.0) tan_el = sin_el * sec_el d_corraz_d_az = 1.0 + P5*cos_az*tan_el + P6*sin_az*tan_el + \ @@ -230,7 +229,7 @@ def reverse(self, pointed_az, pointed_el): Elevation angle(s) before pointing correction, in radians """ # Maximum difference between input az/el and pointing-corrected version of final output az/el - tolerance = deg2rad(0.01 / 3600) + tolerance = np.radians(0.01 / 3600) # Initial guess of uncorrected az/el is the corrected az/el minus fixed offsets az, el = pointed_az - self['P1'], pointed_el - self['P7'] # Solve F(az, el) = apply(az, el) - (pointed_az, pointed_el) = 0 via Newton's method, should converge quickly @@ -253,7 +252,7 @@ def reverse(self, pointed_az, pointed_el): max_error, max_az, max_el = np.vstack((sky_error, pointed_az, pointed_el))[:, np.argmax(sky_error)] logger.warning('Reverse pointing correction did not converge in %d iterations - ' 'maximum error is %f arcsecs at (az, el) = (%f, %f) radians', - iteration + 1, rad2deg(max_error) * 3600., max_az, max_el) + iteration + 1, np.degrees(max_error) * 3600., max_az, max_el) return az, el def fit(self, az, el, delta_az, delta_el, sigma_daz=None, sigma_del=None, enabled_params=None): diff --git a/katpoint/refraction.py b/katpoint/refraction.py index 82419eb..930b0b2 100644 --- a/katpoint/refraction.py +++ b/katpoint/refraction.py @@ -23,7 +23,7 @@ import numpy as np -from .ephem_extra import rad2deg, deg2rad, is_iterable +from .ephem_extra import is_iterable logger = logging.getLogger(__name__) @@ -94,14 +94,14 @@ def refraction_offset_vlbi(el, temperature_C, pressure_hPa, humidity_percent): sn = 77.6 * (pressure_hPa + (4810.0 * cvt * pp) / temperature_K) / temperature_K # Compute refraction at elevation (clipped at 1 degree to avoid cot(el) blow-up at horizon) - el_deg = np.clip(rad2deg(el), 1.0, 90.0) + el_deg = np.clip(np.degrees(el), 1.0, 90.0) aphi = a / ((el_deg + b) ** c) dele = -d / ((el_deg + e) ** f) - zenith_angle = deg2rad(90. - el_deg) + zenith_angle = np.radians(90. - el_deg) bphi = g * (np.tan(zenith_angle) + dele) # Threw out an (el < 0.01) check here, which will never succeed because el is clipped to be above 1.0 [LS] - return deg2rad(bphi * sn - aphi) + return np.radians(bphi * sn - aphi) class RefractionCorrection: @@ -196,12 +196,12 @@ def reverse(self, refracted_el, temperature_C, pressure_hPa, humidity_percent): Elevation angle(s) before refraction correction, in radians """ # Maximum difference between input elevation and refraction-corrected version of final output elevation - tolerance = deg2rad(0.01 / 3600) + tolerance = np.radians(0.01 / 3600) # Assume offset from corrected el is similar to offset from uncorrected el -> get lower bound on desired el close_offset = self.offset(refracted_el, temperature_C, pressure_hPa, humidity_percent) lower = refracted_el - 4 * np.abs(close_offset) # We know that corrected el > uncorrected el (mostly) -> this becomes upper bound on desired el - upper = refracted_el + deg2rad(1. / 3600.) + upper = refracted_el + np.radians(1. / 3600.) # Do binary search for desired el within this range (but cap iterations in case of a mishap) # This assumes that refraction-corrected elevation is monotone function of uncorrected elevation for iteration in range(40): @@ -221,5 +221,5 @@ def reverse(self, refracted_el, temperature_C, pressure_hPa, humidity_percent): else: logger.warning('Reverse refraction correction did not converge in ' '%d iterations - elevation differs by at most %f arcsecs', - iteration + 1, rad2deg(np.abs(test_el - refracted_el).max()) * 3600.) + iteration + 1, np.degrees(np.abs(test_el - refracted_el).max()) * 3600.) return el diff --git a/katpoint/test/test_pointing.py b/katpoint/test/test_pointing.py index 9f2d4d6..426751b 100644 --- a/katpoint/test/test_pointing.py +++ b/katpoint/test/test_pointing.py @@ -27,8 +27,8 @@ @pytest.fixture def pointing_grid(): """Generate a grid of (az, el) values in natural antenna coordinates.""" - az_range = katpoint.deg2rad(np.arange(-185.0, 275.0, 5.0)) - el_range = katpoint.deg2rad(np.arange(0.0, 86.0, 1.0)) + az_range = np.radians(np.arange(-185.0, 275.0, 5.0)) + el_range = np.radians(np.arange(0.0, 86.0, 1.0)) mesh_az, mesh_el = np.meshgrid(az_range, el_range) az = mesh_az.ravel() el = mesh_el.ravel() @@ -39,7 +39,7 @@ def pointing_grid(): def params(): """Generate random parameters for a pointing model.""" # Generate random parameter values with this spread - param_stdev = katpoint.deg2rad(20. / 60.) + param_stdev = np.radians(20. / 60.) num_params = len(katpoint.PointingModel()) params = param_stdev * np.random.randn(num_params) return params diff --git a/katpoint/test/test_refraction.py b/katpoint/test/test_refraction.py index 958632a..c6258a5 100644 --- a/katpoint/test/test_refraction.py +++ b/katpoint/test/test_refraction.py @@ -41,7 +41,7 @@ def test_refraction_basic(): def test_refraction_closure(): """Test closure between refraction correction and its reverse operation.""" rc = katpoint.RefractionCorrection() - el = katpoint.deg2rad(np.arange(0.0, 90.1, 0.1)) + el = np.radians(np.arange(0.0, 90.1, 0.1)) # Generate random meteorological data (a single measurement, hopefully sensible) temp = -10. + 50. * np.random.rand() pressure = 900. + 200. * np.random.rand() diff --git a/katpoint/test/test_target.py b/katpoint/test/test_target.py index 92eee96..2b7f76e 100644 --- a/katpoint/test/test_target.py +++ b/katpoint/test/test_target.py @@ -374,7 +374,7 @@ def test_separation(): def test_projection(): """Test projection.""" - az, el = katpoint.deg2rad(50.0), katpoint.deg2rad(80.0) + az, el = np.radians(50.0), np.radians(80.0) x, y = TARGET.sphere_to_plane(az, el, TS, ANT1) re_az, re_el = TARGET.plane_to_sphere(x, y, TS, ANT1) np.testing.assert_almost_equal(re_az, az, decimal=12) diff --git a/scripts/bae_optical_pointing_sources.py b/scripts/bae_optical_pointing_sources.py index 057eed6..b2741d3 100644 --- a/scripts/bae_optical_pointing_sources.py +++ b/scripts/bae_optical_pointing_sources.py @@ -80,10 +80,10 @@ timestamp = katpoint.Timestamp() ra, dec = np.array([t.radec(timestamp) for t in cat]).transpose() constellation = [t.aliases[0].partition(' ')[2][:3] if t.aliases else 'SOL' for t in cat] -ra, dec = katpoint.rad2deg(ra), katpoint.rad2deg(dec) +ra, dec = np.degrees(ra), np.degrees(dec) az, el = np.hstack([targ.azel([katpoint.Timestamp(timestamp + t) for t in range(0, 24 * 3600, 30 * 60)]) for targ in cat]) -az, el = katpoint.rad2deg(az), katpoint.rad2deg(el) +az, el = np.degrees(az), np.degrees(el) plt.figure(1) plt.clf() diff --git a/scripts/kuehr1Jy_sources.py b/scripts/kuehr1Jy_sources.py index 15689d2..605b00a 100644 --- a/scripts/kuehr1Jy_sources.py +++ b/scripts/kuehr1Jy_sources.py @@ -69,7 +69,7 @@ names = '1Jy ' + src['_1Jy'] if len(src['_3C']) > 0: names += ' | *' + src['_3C'] - ra, dec = katpoint.deg2rad(src['_RAJ2000']), katpoint.deg2rad(src['_DEJ2000']) + ra, dec = np.radians(src['_RAJ2000']), np.radians(src['_DEJ2000']) tags_ra_dec = katpoint.construct_radec_target(ra, dec).add_tags('J2000').description # Extract flux data for the current source from flux table flux = flux_table[flux_table['_1Jy'] == src['_1Jy']] diff --git a/scripts/tabara_sources.py b/scripts/tabara_sources.py index f5b503d..fac7a82 100644 --- a/scripts/tabara_sources.py +++ b/scripts/tabara_sources.py @@ -120,7 +120,7 @@ if len(src['OName']) > 0: names += ' | ' + src['OName'] ra, dec = atca_cat[src['Name']].radec() if use_atca else \ - (katpoint.deg2rad(src['_RAJ2000']), katpoint.deg2rad(src['_DEJ2000'])) + (np.radians(src['_RAJ2000']), np.radians(src['_DEJ2000'])) tags_ra_dec = katpoint.construct_radec_target(ra, dec).add_tags('J2000 ' + src['Type']).description # Extract polarisation data for the current source from pol table pol_data = pol_table[pol_table['Name'] == src['Name']] From a5cb2dd74e0a08e1fcaae36bb282e0e262c19857 Mon Sep 17 00:00:00 2001 From: Ludwig Schwardt Date: Thu, 3 Sep 2020 12:52:48 +0200 Subject: [PATCH 108/122] Get rid of katpoint.lightspeed Use the corresponding Astropy constant instead. This changes the public API. End users can replace lightspeed with `astropy.constants.c.to_value(u.m / u.s)`. --- katpoint/__init__.py | 2 +- katpoint/delay.py | 9 ++++++--- katpoint/ephem_extra.py | 3 --- katpoint/target.py | 5 +++-- scripts/tabara_sources.py | 4 +++- 5 files changed, 13 insertions(+), 10 deletions(-) diff --git a/katpoint/__init__.py b/katpoint/__init__.py index faf3d4c..946d921 100644 --- a/katpoint/__init__.py +++ b/katpoint/__init__.py @@ -34,7 +34,7 @@ from .timestamp import Timestamp from .flux import FluxDensityModel, FluxError from .catalogue import Catalogue, specials -from .ephem_extra import lightspeed, wrap_angle, is_iterable +from .ephem_extra import wrap_angle, is_iterable from .conversion import (lla_to_ecef, ecef_to_lla, enu_to_ecef, ecef_to_enu, azel_to_enu, enu_to_azel, hadec_to_enu, enu_to_xyz) from .projection import sphere_to_plane, plane_to_sphere diff --git a/katpoint/delay.py b/katpoint/delay.py index c8c63ae..07aa321 100644 --- a/katpoint/delay.py +++ b/katpoint/delay.py @@ -25,17 +25,20 @@ import json import numpy as np +import astropy.units as u +import astropy.constants as const from .model import Parameter, Model from .conversion import azel_to_enu -from .ephem_extra import lightspeed, is_iterable +from .ephem_extra import is_iterable from .target import construct_radec_target # Speed of EM wave in fixed path (typically due to cables / clock distribution). # This number is not critical - only meant to convert delays to "nice" lengths. # Typical factors are: fibre = 0.7, coax = 0.84. -FIXEDSPEED = 0.7 * lightspeed +LIGHTSPEED = const.c.to_value(u.m / u.s) +FIXEDSPEED = 0.7 * LIGHTSPEED logger = logging.getLogger(__name__) @@ -71,7 +74,7 @@ def __init__(self, model=None): Model.__init__(self, params) self.set(model) # The EM wave velocity associated with each parameter - self._speeds = np.array([lightspeed] * 3 + [FIXEDSPEED] * 2 + [lightspeed]) + self._speeds = np.array([LIGHTSPEED] * 3 + [FIXEDSPEED] * 2 + [LIGHTSPEED]) @property def delay_params(self): diff --git a/katpoint/ephem_extra.py b/katpoint/ephem_extra.py index bd95fb8..525a725 100644 --- a/katpoint/ephem_extra.py +++ b/katpoint/ephem_extra.py @@ -22,9 +22,6 @@ # --- Helper functions # -------------------------------------------------------------------------------------------------- -# The speed of light, in metres per second -lightspeed = 299792458.0 - def is_iterable(x): """Checks if object is iterable (but not a string or 0-dimensional array).""" diff --git a/katpoint/target.py b/katpoint/target.py index 243e29d..92f0611 100644 --- a/katpoint/target.py +++ b/katpoint/target.py @@ -18,6 +18,7 @@ import numpy as np import astropy.units as u +import astropy.constants as const from astropy.coordinates import SkyCoord # High-level coordinates from astropy.coordinates import ICRS, Galactic, FK4, AltAz, CIRS # Low-level frames from astropy.coordinates import Latitude, Longitude, Angle # Angles @@ -25,7 +26,7 @@ from .timestamp import Timestamp, delta_seconds from .flux import FluxDensityModel -from .ephem_extra import is_iterable, lightspeed +from .ephem_extra import is_iterable from .conversion import azel_to_enu from .projection import sphere_to_plane, sphere_to_ortho, plane_to_sphere from .body import (Body, FixedBody, SolarSystemBody, EarthSatelliteBody, @@ -521,7 +522,7 @@ def geometric_delay(self, antenna2, timestamp=None, antenna=None): targetdirs = np.array(azel_to_enu(azel.az.rad, azel.alt.rad)) # Dot product of vectors is w coordinate, and # delay is time taken by EM wave to traverse this - delays = -np.einsum('j,j...', baseline_m, targetdirs) / lightspeed + delays = -np.einsum('j,j...', baseline_m, targetdirs) / const.c.to_value(u.m / u.s) return delays[..., 1], delays[..., 2] - delays[..., 0] def uvw_basis(self, timestamp=None, antenna=None): diff --git a/scripts/tabara_sources.py b/scripts/tabara_sources.py index fac7a82..0e0d5b3 100644 --- a/scripts/tabara_sources.py +++ b/scripts/tabara_sources.py @@ -54,6 +54,8 @@ from scikits.fitting import PiecewisePolynomial1DFit import katpoint +import astropy.units as u +import astropy.constants as const from astropy.table import Table # Load tables in one shot (don't verify, as the VizieR VOTables contain a deprecated DEFINITIONS element) @@ -124,7 +126,7 @@ tags_ra_dec = katpoint.construct_radec_target(ra, dec).add_tags('J2000 ' + src['Type']).description # Extract polarisation data for the current source from pol table pol_data = pol_table[pol_table['Name'] == src['Name']] - pol_freqs_MHz = katpoint.lightspeed / (0.01 * pol_data['lambda']) / 1e6 + pol_freqs_MHz = const.c.to_value(u.m / u.s) / (0.01 * pol_data['lambda']) / 1e6 pol_percent = pol_data['Pol'] # Remove duplicate frequencies and fit linear interpolator to data as function of frequency pol_freq, pol_perc = [], [] From 284bdc492900662b7ddfe32434ea2e4d0d62fc39 Mon Sep 17 00:00:00 2001 From: Ludwig Schwardt Date: Thu, 3 Sep 2020 13:01:25 +0200 Subject: [PATCH 109/122] Move wrap_angle to top level of package This is a tricky one. The `wrap_angle` function is not used within katpoint itself, but external scripts use it in all sorts of places. There is no obvious NumPy equivalent, but in Astropy one would do `Angle.wrap_at('180d')`. This would require external users to convert all their float angles to Astropy objects. Removing `wrap_angle` from the public API would therefore cause some extra pain. We therefore leave it in for now, but move it out of ephem_extra to the only sensible place - the top. --- katpoint/__init__.py | 14 ++++++++++++-- katpoint/ephem_extra.py | 10 ---------- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/katpoint/__init__.py b/katpoint/__init__.py index 946d921..0a486e2 100644 --- a/katpoint/__init__.py +++ b/katpoint/__init__.py @@ -27,14 +27,15 @@ """ import logging as _logging -import warnings as _warnings + +import numpy as _np from .target import Target, construct_azel_target, construct_radec_target, NonAsciiError from .antenna import Antenna from .timestamp import Timestamp from .flux import FluxDensityModel, FluxError from .catalogue import Catalogue, specials -from .ephem_extra import wrap_angle, is_iterable +from .ephem_extra import is_iterable from .conversion import (lla_to_ecef, ecef_to_lla, enu_to_ecef, ecef_to_enu, azel_to_enu, enu_to_azel, hadec_to_enu, enu_to_xyz) from .projection import sphere_to_plane, plane_to_sphere @@ -43,6 +44,15 @@ from .refraction import RefractionCorrection from .delay import DelayModel, DelayCorrection + +def wrap_angle(angle, period=2.0 * _np.pi): + """Wrap angle into interval centred on zero. + + This wraps the *angle* into the interval -*period* / 2 ... *period* / 2. + """ + return (angle + 0.5 * period) % period - 0.5 * period + + # Hide submodules in module namespace, to avoid confusion with corresponding class names # If the module is reloaded, this will fail - ignore the resulting NameError try: diff --git a/katpoint/ephem_extra.py b/katpoint/ephem_extra.py index 525a725..c955610 100644 --- a/katpoint/ephem_extra.py +++ b/katpoint/ephem_extra.py @@ -16,8 +16,6 @@ """Enhancements to PyEphem.""" -import numpy as np - # -------------------------------------------------------------------------------------------------- # --- Helper functions # -------------------------------------------------------------------------------------------------- @@ -27,11 +25,3 @@ def is_iterable(x): """Checks if object is iterable (but not a string or 0-dimensional array).""" return hasattr(x, '__iter__') and not isinstance(x, str) and \ not (getattr(x, 'shape', None) == ()) - - -def wrap_angle(angle, period=2.0 * np.pi): - """Wrap angle into interval centred on zero. - - This wraps the *angle* into the interval -*period* / 2 ... *period* / 2. - """ - return (angle + 0.5 * period) % period - 0.5 * period From 6e0869ceb155ceec5ff5de448fdaf6f3306ef981 Mon Sep 17 00:00:00 2001 From: Ludwig Schwardt Date: Fri, 4 Sep 2020 12:22:18 +0200 Subject: [PATCH 110/122] Get rid of katpoint.is_iterable The main use of this helper function is to patch together array-based and scalar-based code paths inside the main katpoint functions. Modern NumPy allows most array operations on 0-dimensional arrays, so a better scheme is to cast scalars to arrays, perform the calculations along a single array-based code path and turn 0-d ndarrays back into scalars at the end (if needed) with `ndarray.item()`. Replace is_iterable with a mix of the 0-d ndarray scheme, attempting iteration and handling the exception, and Astropy Time array handling. The refactor of `DelayCorrection.corrections` revealed that the function crashes if the timestamp is a sequence with a single element, but this was also the case in the old code. Figuring that out on another day... There is very little external use of is_iterable (two places in katdal come to mind), so it won't be sorely missed. And poof! ephem_extra.py is gone. This addresses JIRA ticket SPAZA-124. --- katpoint/__init__.py | 1 - katpoint/delay.py | 27 +++++++++++++++------------ katpoint/ephem_extra.py | 27 --------------------------- katpoint/flux.py | 15 +++++---------- katpoint/refraction.py | 14 +++----------- katpoint/target.py | 8 ++++---- katpoint/test/test_delay.py | 7 +++++++ 7 files changed, 34 insertions(+), 65 deletions(-) delete mode 100644 katpoint/ephem_extra.py diff --git a/katpoint/__init__.py b/katpoint/__init__.py index 0a486e2..210184d 100644 --- a/katpoint/__init__.py +++ b/katpoint/__init__.py @@ -35,7 +35,6 @@ from .timestamp import Timestamp from .flux import FluxDensityModel, FluxError from .catalogue import Catalogue, specials -from .ephem_extra import is_iterable from .conversion import (lla_to_ecef, ecef_to_lla, enu_to_ecef, ecef_to_enu, azel_to_enu, enu_to_azel, hadec_to_enu, enu_to_xyz) from .projection import sphere_to_plane, plane_to_sphere diff --git a/katpoint/delay.py b/katpoint/delay.py index 07aa321..ec9999c 100644 --- a/katpoint/delay.py +++ b/katpoint/delay.py @@ -27,11 +27,12 @@ import numpy as np import astropy.units as u import astropy.constants as const +from astropy.time import Time from .model import Parameter, Model from .conversion import azel_to_enu -from .ephem_extra import is_iterable from .target import construct_radec_target +from .timestamp import Timestamp # Speed of EM wave in fixed path (typically due to cables / clock distribution). @@ -313,31 +314,33 @@ def corrections(self, target, timestamp=None, next_timestamp=None, timestamps are provided, each input maps to an array of shape (*T*, 2). """ - if is_iterable(timestamp): + time = Timestamp(timestamp).time + if time.shape == (): + # Use cache for a single timestamp + delays = self._cached_delays(target, time, offset) + next_time = None if next_timestamp is None else Timestamp(next_timestamp).time + else: # Append one more timestamp to get a slope for the last timestamp - last_step = timestamp[-1] - timestamp[-2] - all_times = np.r_[timestamp, [timestamp[-1] + last_step]] - next_timestamp = all_times[1:] + last_step = time[-1] - time[-2] + all_times = np.r_[time, [time[-1] + last_step]] + next_time = Time(all_times[1:]) # Don't use cache, as the next_times are included in all_delays all_delays = np.array([self._calculate_delays(target, t, offset) for t in all_times]).T delays, next_delays = all_delays[:, :-1], all_delays[:, 1:] - else: - # Use cache for a single timestamp - delays = self._cached_delays(target, timestamp, offset) def phase(t0): """The phase associated with delay t0 at the centre frequency.""" return - 2.0 * np.pi * self.sky_centre_freq * t0 delay_corrections = self.extra_delay - delays phase_corrections = - phase(delays) - if next_timestamp is None: + if next_time is None: return (dict(zip(self._inputs, delay_corrections)), dict(zip(self._inputs, phase_corrections))) - step = next_timestamp - timestamp + step = (next_time - time).sec # We still have to get next_delays in the single timestamp case - if not is_iterable(next_timestamp): - next_delays = self._cached_delays(target, next_timestamp, offset) + if next_time.shape == (): + next_delays = self._cached_delays(target, next_time, offset) next_delay_corrections = self.extra_delay - next_delays next_phase_corrections = - phase(next_delays) delay_slopes = (next_delay_corrections - delay_corrections) / step diff --git a/katpoint/ephem_extra.py b/katpoint/ephem_extra.py deleted file mode 100644 index c955610..0000000 --- a/katpoint/ephem_extra.py +++ /dev/null @@ -1,27 +0,0 @@ -################################################################################ -# Copyright (c) 2009-2020, National Research Foundation (SARAO) -# -# Licensed under the BSD 3-Clause License (the "License"); you may not use -# this file except in compliance with the License. You may obtain a copy -# of the License at -# -# https://opensource.org/licenses/BSD-3-Clause -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -################################################################################ - -"""Enhancements to PyEphem.""" - -# -------------------------------------------------------------------------------------------------- -# --- Helper functions -# -------------------------------------------------------------------------------------------------- - - -def is_iterable(x): - """Checks if object is iterable (but not a string or 0-dimensional array).""" - return hasattr(x, '__iter__') and not isinstance(x, str) and \ - not (getattr(x, 'shape', None) == ()) diff --git a/katpoint/flux.py b/katpoint/flux.py index df3b219..18d0493 100644 --- a/katpoint/flux.py +++ b/katpoint/flux.py @@ -20,8 +20,6 @@ import numpy as np -from .ephem_extra import is_iterable - class FluxError(ValueError): """Exception for a flux parsing error.""" @@ -172,14 +170,11 @@ def flux_density(self, freq_MHz): flux_density : float, or array of floats of same shape as *freq_MHz* Flux density in Jy, or np.nan if the frequency is out of range """ - flux = self._flux_density_raw(freq_MHz) * self.iquv_scale[0] - if is_iterable(freq_MHz): - freq_MHz = np.asarray(freq_MHz) - flux[freq_MHz < self.min_freq_MHz] = np.nan - flux[freq_MHz > self.max_freq_MHz] = np.nan - return flux - else: - return flux if (freq_MHz >= self.min_freq_MHz) and (freq_MHz <= self.max_freq_MHz) else np.nan + freq_MHz = np.asarray(freq_MHz) + flux = np.asarray(self._flux_density_raw(freq_MHz) * self.iquv_scale[0]) + flux[freq_MHz < self.min_freq_MHz] = np.nan + flux[freq_MHz > self.max_freq_MHz] = np.nan + return flux if flux.ndim else flux.item() def flux_density_stokes(self, freq_MHz): """Calculate full-Stokes flux density for given observation frequency. diff --git a/katpoint/refraction.py b/katpoint/refraction.py index 930b0b2..ada118a 100644 --- a/katpoint/refraction.py +++ b/katpoint/refraction.py @@ -23,7 +23,6 @@ import numpy as np -from .ephem_extra import is_iterable logger = logging.getLogger(__name__) @@ -209,17 +208,10 @@ def reverse(self, refracted_el, temperature_C, pressure_hPa, humidity_percent): test_el = self.apply(el, temperature_C, pressure_hPa, humidity_percent) if np.all(np.abs(test_el - refracted_el) < tolerance): break - # Handle both scalars and arrays (and lists) as cleanly as possible - if not is_iterable(refracted_el): - if test_el < refracted_el: - lower = el - else: - upper = el - else: - lower = np.where(test_el < refracted_el, el, lower) - upper = np.where(test_el > refracted_el, el, upper) + lower = np.where(test_el < refracted_el, el, lower) + upper = np.where(test_el > refracted_el, el, upper) else: logger.warning('Reverse refraction correction did not converge in ' '%d iterations - elevation differs by at most %f arcsecs', iteration + 1, np.degrees(np.abs(test_el - refracted_el).max()) * 3600.) - return el + return el if el.ndim else el.item() diff --git a/katpoint/target.py b/katpoint/target.py index 92f0611..2bcd16e 100644 --- a/katpoint/target.py +++ b/katpoint/target.py @@ -26,7 +26,6 @@ from .timestamp import Timestamp, delta_seconds from .flux import FluxDensityModel -from .ephem_extra import is_iterable from .conversion import azel_to_enu from .projection import sphere_to_plane, sphere_to_ortho, plane_to_sphere from .body import (Body, FixedBody, SolarSystemBody, EarthSatelliteBody, @@ -634,9 +633,9 @@ def uvw(self, antenna2, timestamp=None, antenna=None): # Obtain basis vectors basis = self.uvw_basis(time, antenna) # Obtain baseline vector from reference antenna to second antenna - if is_iterable(antenna2): + try: baseline_m = np.stack([antenna.baseline_toward(a2) for a2 in antenna2]) - else: + except TypeError: baseline_m = antenna.baseline_toward(antenna2) # Apply linear coordinate transformation. A single call np.dot won't # work for both the scalar and array case, so we explicitly specify the @@ -707,7 +706,8 @@ def flux_density(self, flux_freq_MHz=None): raise ValueError('Please specify frequency at which to measure flux density') if self.flux_model is None: # Target has no specified flux density - return np.full(np.shape(flux_freq_MHz), np.nan) if is_iterable(flux_freq_MHz) else np.nan + flux = np.full(np.shape(flux_freq_MHz), np.nan) + return flux if flux.ndim else flux.item() return self.flux_model.flux_density(flux_freq_MHz) def flux_density_stokes(self, flux_freq_MHz=None): diff --git a/katpoint/test/test_delay.py b/katpoint/test/test_delay.py index 1beed09..ebc5f06 100644 --- a/katpoint/test/test_delay.py +++ b/katpoint/test/test_delay.py @@ -87,6 +87,11 @@ def test_correction(self): extra_delay = self.delays.extra_delay delay0, phase0 = self.delays.corrections(self.target1, self.ts) delay1, phase1 = self.delays.corrections(self.target1, self.ts, self.ts + 1.0) + # First check dimensions + assert np.shape(delay0['A2h']) == () + assert np.shape(phase0['A2h']) == () + assert np.shape(delay1['A2h']) == (2,) + assert np.shape(phase1['A2h']) == (2,) # This target is special - direction perpendicular to baseline (and stationary) assert delay0['A2h'] == extra_delay, 'Delay for ant2h should be zero' assert delay0['A2v'] == extra_delay, 'Delay for ant2v should be zero' @@ -102,6 +107,8 @@ def test_correction(self): np.testing.assert_almost_equal(delay1['A2h'][1], -tgt_delay_rate, decimal=13) # Test vector version delay2, phase2 = self.delays.corrections(self.target2, (self.ts - 0.5, self.ts + 0.5)) + assert np.shape(delay2['A2h']) == (2, 2) + assert np.shape(phase2['A2h']) == (2, 2) np.testing.assert_equal(delay2['A2h'][0], delay1['A2h']) np.testing.assert_equal(phase2['A2h'][0], phase1['A2h']) From d0265fd8e62793ac774a79af2238aa742c8afbcb Mon Sep 17 00:00:00 2001 From: Ludwig Schwardt Date: Wed, 9 Sep 2020 15:33:17 +0200 Subject: [PATCH 111/122] MR fixes Call parameter `sexagesimal_unit` to be clear. Remove Latitude and Longitude where it is superfluous. Clarify that delay corrections cannot operate on arrays with fewer than two timestamps. Add more angle conversion tests. --- katpoint/body.py | 17 ++++++++--------- katpoint/delay.py | 7 ++++--- katpoint/target.py | 17 ++++++++--------- katpoint/test/test_body.py | 28 ++++++++++++++-------------- 4 files changed, 34 insertions(+), 35 deletions(-) diff --git a/katpoint/body.py b/katpoint/body.py index c2223b6..ae011bf 100644 --- a/katpoint/body.py +++ b/katpoint/body.py @@ -21,7 +21,7 @@ import numpy as np import astropy.units as u from astropy.time import Time -from astropy.coordinates import SkyCoord, ICRS, AltAz, Latitude, Longitude, Angle +from astropy.coordinates import SkyCoord, ICRS, AltAz, Angle from astropy.coordinates import solar_system_ephemeris, get_body from astropy.coordinates import TEME, CartesianDifferential, CartesianRepresentation from sgp4.api import Satrec, WGS72 @@ -29,7 +29,7 @@ from sgp4.exporter import export_tle -def to_angle(s, sexagesimal=u.deg): +def to_angle(s, sexagesimal_unit=u.deg): """Construct an `Angle` with default units. This creates an :class:`~astropy.coordinates.Angle` with the following @@ -37,7 +37,7 @@ def to_angle(s, sexagesimal=u.deg): - A number is in radians. - A decimal string ('123.4') is in degrees. - - A sexagesimal string ('12:34:56.7') or tuple has unit `sexagesimal`. + - A sexagesimal string ('12:34:56.7') or tuple has `sexagesimal_unit`. In addition, bytes are decoded to ASCII strings to normalize user inputs. @@ -45,7 +45,7 @@ def to_angle(s, sexagesimal=u.deg): ---------- s : :class:`~astropy.coordinates.Angle` or equivalent Anything accepted by `Angle` and also unitless strings, numbers, tuples - sexagesimal : :class:`~astropy.units.UnitBase` or str, optional + sexagesimal_unit : :class:`~astropy.units.UnitBase` or str, optional The unit applied to sexagesimal strings and tuples Returns @@ -61,7 +61,7 @@ def to_angle(s, sexagesimal=u.deg): s = s.decode(encoding='ascii') # We now have a number, string or tuple without a unit if isinstance(s, str) and ':' in s or isinstance(s, tuple): - return Angle(s, unit=sexagesimal) + return Angle(s, unit=sexagesimal_unit) if isinstance(s, str): return Angle(s, unit=u.deg) else: @@ -157,11 +157,10 @@ def from_edb(cls, line): """Construct a `FixedBody` from an XEphem database (EDB) entry.""" fields = line.split(',') name = fields[0] + # Discard proper motion for now (the part after the |) ra = fields[2].split('|')[0] dec = fields[3].split('|')[0] - ra = Longitude(ra, unit=u.hour) - dec = Latitude(dec, unit=u.deg) - return cls(name, SkyCoord(ra=ra, dec=dec, frame=ICRS)) + return cls(name, SkyCoord(ra=Angle(ra, unit=u.hour), dec=Angle(dec, unit=u.deg))) def to_edb(self): """Create an XEphem database (EDB) entry for fixed body ("f"). @@ -408,7 +407,7 @@ class StationaryBody(Body): """ def __init__(self, az, el, name=None): - self.coord = AltAz(az=Longitude(to_angle(az)), alt=Latitude(to_angle(el))) + self.coord = AltAz(az=to_angle(az), alt=to_angle(el)) if not name: name = "Az: {} El: {}".format(self.coord.az.to_string(sep=':', unit=u.deg), self.coord.alt.to_string(sep=':', unit=u.deg)) diff --git a/katpoint/delay.py b/katpoint/delay.py index ec9999c..5789c74 100644 --- a/katpoint/delay.py +++ b/katpoint/delay.py @@ -288,9 +288,10 @@ def corrections(self, target, timestamp=None, next_timestamp=None, target : :class:`Target` object Target providing direction for geometric delays timestamp : :class:`~astropy.time.Time`, :class:`Timestamp` or equivalent, optional - Timestamp(s) when delays are evaluated (default is now). If more - than one timestamp is given, the corrections will include slopes - to be used for linear interpolation between the times. + Timestamp(s) when delays are evaluated (default is now). If an array + of timestamps is given (in which case, it must contain at least two + elements), the corrections will include slopes to be used for linear + interpolation between the times. next_timestamp : :class:`~astropy.time.Time`, :class:`Timestamp` or equivalent, optional Timestamp when next delay will be evaluated, used to determine a slope for linear interpolation (default is no slope). This is diff --git a/katpoint/target.py b/katpoint/target.py index 2bcd16e..d0e80d4 100644 --- a/katpoint/target.py +++ b/katpoint/target.py @@ -19,9 +19,8 @@ import numpy as np import astropy.units as u import astropy.constants as const -from astropy.coordinates import SkyCoord # High-level coordinates -from astropy.coordinates import ICRS, Galactic, FK4, AltAz, CIRS # Low-level frames -from astropy.coordinates import Latitude, Longitude, Angle # Angles +from astropy.coordinates import SkyCoord, Angle +from astropy.coordinates import ICRS, Galactic, FK4, AltAz, CIRS from astropy.time import Time from .timestamp import Timestamp, delta_seconds @@ -939,8 +938,8 @@ def construct_target_params(description): if len(fields) < 4: raise ValueError("Target description '%s' contains *radec* body with no (ra, dec) coordinates" % description) - ra = Longitude(to_angle(fields[2], sexagesimal=u.hour)) - dec = Latitude(to_angle(fields[3])) + ra = to_angle(fields[2], sexagesimal_unit=u.hour) + dec = to_angle(fields[3]) if not preferred_name: preferred_name = "Ra: %s Dec: %s" % (ra, dec) # Extract epoch info from tags @@ -959,8 +958,8 @@ def construct_target_params(description): l, b = float(fields[2]), float(fields[3]) if not preferred_name: preferred_name = "Galactic l: %.4f b: %.4f" % (l, b) - body = FixedBody(preferred_name, SkyCoord(l=Longitude(l, unit=u.deg), - b=Latitude(b, unit=u.deg), frame=Galactic)) + body = FixedBody(preferred_name, SkyCoord(l=Angle(l, unit=u.deg), + b=Angle(b, unit=u.deg), frame=Galactic)) elif body_type == 'tle': if len(fields) < 4: @@ -1079,8 +1078,8 @@ def construct_radec_target(ra, dec): target : :class:`Target` object Constructed target object """ - ra = Longitude(to_angle(ra, sexagesimal=u.hour)) - dec = Latitude(to_angle(dec)) + ra = to_angle(ra, sexagesimal_unit=u.hour) + dec = to_angle(dec) name = "Ra: %s Dec: %s" % (ra, dec) body = FixedBody(name, SkyCoord(ra=ra, dec=dec, frame=ICRS)) return Target(body, 'radec') diff --git a/katpoint/test/test_body.py b/katpoint/test/test_body.py index 7e4a3a2..3891744 100644 --- a/katpoint/test/test_body.py +++ b/katpoint/test/test_body.py @@ -24,8 +24,7 @@ import pytest import astropy.units as u from astropy.time import Time -from astropy.coordinates import SkyCoord, ICRS, AltAz -from astropy.coordinates import EarthLocation, Latitude, Longitude +from astropy.coordinates import SkyCoord, ICRS, AltAz, EarthLocation, Angle from katpoint.body import Body, FixedBody, SolarSystemBody, EarthSatelliteBody, to_angle from katpoint.test.helper import check_separation @@ -38,24 +37,26 @@ HAS_SKYFIELD = True -@pytest.mark.parametrize("angle, angle_deg", [('10:00:00', 10), ('10.0', 10), - ((10 * u.deg).to_value(u.rad), 10), - ('10d00m00s', 10), ((10, 0, 0), 10)]) +@pytest.mark.parametrize("angle, angle_deg", [('10:00:00', 10), ('10:45:00', 10.75), ('10.0', 10), + ((10 * u.deg).to_value(u.rad), pytest.approx(10)), + ('10d00m00s', 10), ((10, 0, 0), 10), + ('10h00m00s', pytest.approx(150))]) def test_angle_from_degrees(angle, angle_deg): - assert to_angle(angle, sexagesimal=u.deg).deg == angle_deg + assert to_angle(angle, sexagesimal_unit=u.deg).deg == angle_deg -@pytest.mark.parametrize("angle, angle_hour", [('10:00:00', 10), ('150.0', pytest.approx(10)), +@pytest.mark.parametrize("angle, angle_hour", [('10:00:00', 10), ('10:45:00', 10.75), + ('150.0', pytest.approx(10)), ((150 * u.deg).to_value(u.rad), pytest.approx(10)), - ('10h00m00s', 10), ((10, 0, 0), 10)]) + ('10h00m00s', 10), ((10, 0, 0), 10), + ('10d00m00s', pytest.approx(10 / 15))]) def test_angle_from_hours(angle, angle_hour): - assert to_angle(angle, sexagesimal=u.hour).hour == angle_hour + assert to_angle(angle, sexagesimal_unit=u.hour).hour == angle_hour def _get_fixed_body(ra_str, dec_str): - ra = Longitude(ra_str, unit=u.hour) - dec = Latitude(dec_str, unit=u.deg) - return FixedBody('name', SkyCoord(ra=ra, dec=dec, frame=ICRS)) + return FixedBody('name', SkyCoord(ra=Angle(ra_str, unit=u.hour), + dec=Angle(dec_str, unit=u.deg))) TLE_NAME = 'GPS BIIA-21 (PRN 09)' @@ -113,8 +114,7 @@ def test_earth_satellite_vs_skyfield(): t = ts.from_astropy(obstime) towards_sat = (satellite - antenna).at(t) alt, az, distance = towards_sat.altaz() - altaz = AltAz(alt=Latitude(alt.radians, unit=u.rad), - az=Longitude(az.radians, unit=u.rad), + altaz = AltAz(alt=Angle(alt.radians, unit=u.rad), az=Angle(az.radians, unit=u.rad), obstime=obstime, location=LOCATION) check_separation(altaz, TLE_AZ, TLE_EL, 0.5 * u.arcsec) From 3f910ef6c43088dde65df7ee3812d2e48b339303 Mon Sep 17 00:00:00 2001 From: Ludwig Schwardt Date: Wed, 9 Sep 2020 15:49:14 +0200 Subject: [PATCH 112/122] Use Skyfield's nice angle conversions --- katpoint/test/test_body.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/katpoint/test/test_body.py b/katpoint/test/test_body.py index 3891744..c2c25bb 100644 --- a/katpoint/test/test_body.py +++ b/katpoint/test/test_body.py @@ -114,8 +114,7 @@ def test_earth_satellite_vs_skyfield(): t = ts.from_astropy(obstime) towards_sat = (satellite - antenna).at(t) alt, az, distance = towards_sat.altaz() - altaz = AltAz(alt=Angle(alt.radians, unit=u.rad), az=Angle(az.radians, unit=u.rad), - obstime=obstime, location=LOCATION) + altaz = AltAz(alt=alt.to(u.rad), az=az.to(u.rad), obstime=obstime, location=LOCATION) check_separation(altaz, TLE_AZ, TLE_EL, 0.5 * u.arcsec) From 5bd17580c73684af9b890dfec1dfcbeb03056ba7 Mon Sep 17 00:00:00 2001 From: Ludwig Schwardt Date: Fri, 11 Sep 2020 09:36:18 +0200 Subject: [PATCH 113/122] Rename earth_location -> location This matches its common name within Astropy (e.g. SkyCoord.location). --- katpoint/antenna.py | 40 ++++++++++++++++++++-------------------- katpoint/target.py | 2 +- 2 files changed, 21 insertions(+), 21 deletions(-) diff --git a/katpoint/antenna.py b/katpoint/antenna.py index 5943474..c378c3f 100644 --- a/katpoint/antenna.py +++ b/katpoint/antenna.py @@ -121,13 +121,13 @@ class Antenna: ref_position_wgs84 : tuple of 3 floats WGS84 reference position (latitude and longitude in radians, and altitude in metres) - earth_location :class:`astropy.coordinates.EarthLocation` object + location :class:`astropy.coordinates.EarthLocation` object Underlying object used for pointing calculations pressure :class:`astropy.units.Quantity` Atrmospheric pressure used to refraction calculations - ref_earth_location :class:`astropy.coordinates.EarthLocation` object + ref_location :class:`astropy.coordinates.EarthLocation` object Array reference location for antenna in an array (same as - *earth_location* for a stand-alone antenna) + *location* for a stand-alone antenna) ref_pressure :class:`astropy.units.Quantity` Atrmospheric pressure used to refraction calculations @@ -207,35 +207,35 @@ def __init__(self, name, latitude=None, longitude=None, altitude=None, height = float(altitude) * u.meter # Disable astropy's built-in refraction model. self.ref_pressure = 0.0 * u.bar - self.ref_earth_location = EarthLocation(lat=lat, lon=lon, height=height) + self.ref_location = EarthLocation(lat=lat, lon=lon, height=height) - self.ref_position_wgs84 = (self.ref_earth_location.lat.rad, - self.ref_earth_location.lon.rad, - self.ref_earth_location.height.to(u.meter).value) + self.ref_position_wgs84 = (self.ref_location.lat.rad, + self.ref_location.lon.rad, + self.ref_location.height.to(u.meter).value) if self.delay_model: dm = self.delay_model self.position_enu = (dm['POS_E'], dm['POS_N'], dm['POS_U']) # Convert ENU offset to ECEF coordinates of antenna, and then to WGS84 coordinates - self.position_ecef = enu_to_ecef(self.ref_earth_location.lat.rad, - self.ref_earth_location.lon.rad, - self.ref_earth_location.height.to(u.meter).value, + self.position_ecef = enu_to_ecef(self.ref_location.lat.rad, + self.ref_location.lon.rad, + self.ref_location.height.to(u.meter).value, *self.position_enu) lat, lon, elevation = ecef_to_lla(*self.position_ecef) lat = Latitude(lat, unit=u.rad) lon = Longitude(lon, unit=u.rad) self.pressure = 0.0 - self.earth_location = EarthLocation(lat=lat, lon=lon, height=height) - self.position_wgs84 = (self.earth_location.lat.rad, - self.earth_location.lon.rad, - self.earth_location.height.to(u.meter).value) + self.location = EarthLocation(lat=lat, lon=lon, height=height) + self.position_wgs84 = (self.location.lat.rad, + self.location.lon.rad, + self.location.height.to(u.meter).value) else: - self.earth_location = self.ref_earth_location + self.location = self.ref_location self.pressure = self.ref_pressure self.position_enu = (0.0, 0.0, 0.0) - self.position_wgs84 = lat, lon, alt = (self.earth_location.lat.rad, - self.earth_location.lon.rad, - self.earth_location.height.to(u.meter).value) + self.position_wgs84 = lat, lon, alt = (self.location.lat.rad, + self.location.lon.rad, + self.location.height.to(u.meter).value) self.position_ecef = enu_to_ecef(lat, lon, alt, *self.position_enu) def __str__(self): @@ -277,7 +277,7 @@ def description(self): """Complete string representation of antenna object, sufficient to reconstruct it.""" # These fields are used to build up the antenna description string fields = [self.name] - location = self.ref_earth_location if self.delay_model else self.earth_location + location = self.ref_location if self.delay_model else self.location fields += [location.lat.to_string(sep=':', unit=u.deg)] fields += [location.lon.to_string(sep=':', unit=u.deg)] # State height to nearest micrometre (way overkill) to get rid of numerical fluff, @@ -335,7 +335,7 @@ def local_sidereal_time(self, timestamp=None): Local apparent sidereal time(s) """ time = Timestamp(timestamp).time - return time.sidereal_time('apparent', longitude=self.earth_location.lon) + return time.sidereal_time('apparent', longitude=self.location.lon) def array_reference_antenna(self, name='array'): """Synthetic antenna at the delay model reference position of this antenna. diff --git a/katpoint/target.py b/katpoint/target.py index d0e80d4..9d19588 100644 --- a/katpoint/target.py +++ b/katpoint/target.py @@ -302,7 +302,7 @@ def _normalise_antenna(self, antenna, required=False): antenna = self.antenna if required and antenna is None: raise ValueError('Antenna object needed to calculate target position') - location = antenna.earth_location if antenna is not None else None + location = antenna.location if antenna is not None else None return antenna, location def azel(self, timestamp=None, antenna=None): From d595a5feffe09c68130a9ff9479e0bd21c459b81 Mon Sep 17 00:00:00 2001 From: Ludwig Schwardt Date: Tue, 15 Sep 2020 11:29:13 +0200 Subject: [PATCH 114/122] Rework Antenna initialisation Construct an Antenna from an Astropy `EarthLocation` instead of raw lat-lon-alt. The WGS84 route is still used to combine the delay model's ENU parameters with the reference location, but this happens internally and is really meant for description strings. This breaks the "power user" Antenna initialiser but there are few of those users (and they'll like the new __init__ more :-)). This also fixes an egregious bug. The location used the altitude of the ref_location by mistake, because the one variable was called `elevation` and the other one was called `height`. An Antenna is now allowed to have no name (still considering the ramifications...). This enables the following three main initialisation routes, aka The API: - Antenna('description string') - Antenna(another Antenna()) - Antenna(EarthLocation()) Additionally, split out the description string parser to its own slightly more efficient factory method: - Antenna.from_description('description string') The __str__ method now returns the description string, which seems neater than a contrived string that no-one looks at (leave that one for __repr__). It also removes the need for `format_katcp`, a MeerKAT-specific anachronism. Allow additional parameters to override the contents of a description string or existing Antenna object during initialisation. Previously this was considered an error, mostly because the first parameter used to perform double duty as the string `name` or a description string, and this was an extra check to catch broken names or descriptions. The new scheme seems more natural. Turn the position_* tuples into properties to make them read-only. They all derive from ref_location + delay_model (or location). The array reference antenna has no need for a diameter or beamwidth. Use the reference location during construction to preserve accuracy. Add extra unit tests for Antenna initialisation (to prevent a future egregious bug). This addresses JIRA ticket SPAZA-260. --- katpoint/antenna.py | 175 +++++++++++++++------------------- katpoint/test/test_antenna.py | 56 +++++++++-- 2 files changed, 122 insertions(+), 109 deletions(-) diff --git a/katpoint/antenna.py b/katpoint/antenna.py index c378c3f..15437bc 100644 --- a/katpoint/antenna.py +++ b/katpoint/antenna.py @@ -21,15 +21,19 @@ and other parameters that affect pointing and delay calculations. """ -import numpy as np import astropy.units as u -from astropy.coordinates import Latitude, Longitude, EarthLocation +from astropy.coordinates import EarthLocation +from .body import to_angle from .timestamp import Timestamp -from .conversion import enu_to_ecef, ecef_to_lla, lla_to_ecef, ecef_to_enu +from .conversion import enu_to_ecef, lla_to_ecef, ecef_to_enu from .pointing import PointingModel from .delay import DelayModel + +# FWHM beamwidth of a Gaussian-tapered circular dish, as a multiple of lambda / D +DEFAULT_BEAMWIDTH = 1.22 + # -------------------------------------------------------------------------------------------------- # --- CLASS : Antenna # -------------------------------------------------------------------------------------------------- @@ -123,13 +127,9 @@ class Antenna: altitude in metres) location :class:`astropy.coordinates.EarthLocation` object Underlying object used for pointing calculations - pressure :class:`astropy.units.Quantity` - Atrmospheric pressure used to refraction calculations ref_location :class:`astropy.coordinates.EarthLocation` object Array reference location for antenna in an array (same as *location* for a stand-alone antenna) - ref_pressure :class:`astropy.units.Quantity` - Atrmospheric pressure used to refraction calculations Raises ------ @@ -154,99 +154,39 @@ class Antenna: and Up offsets are generally specified up to millimetres. """ - def __init__(self, name, latitude=None, longitude=None, altitude=None, - diameter=0.0, delay_model=None, pointing_model=None, - beamwidth=1.22): - if isinstance(name, Antenna): - name = name.description - if not name and latitude is None: - raise ValueError('Empty antenna description string %r' % (name,)) - # The presence of a comma indicates that a description string is passed in - parse this string into parameters - if name.find(',') >= 0: - try: - name.encode('ascii') - except UnicodeError: - raise ValueError("Antenna description string %r contains non-ASCII characters" % (name,)) - # Cannot have other parameters if description string is given - this is a safety check - if not (latitude is None and longitude is None and altitude is None): - raise ValueError("First parameter '%s' contains comma" % (name,) + - 'and is assumed to be description string - cannot have other parameters') - # Split description string on commas - fields = [s.strip() for s in name.split(',')] - # Extract required fields - if len(fields) < 4: - raise ValueError("Antenna description string '%s' has less than four fields" % (name,)) - name, latitude, longitude, altitude = fields[:4] - # Extract optional fields - try: - diameter = fields.pop(4) - delay_model = fields.pop(4) - pointing_model = fields.pop(4) - beamwidth = fields.pop(4) - except IndexError: - pass - + def __init__(self, antenna, name='', diameter=0.0, delay_model=None, + pointing_model=None, beamwidth=0.0): + if isinstance(antenna, Antenna): + # A simple way to make a deep copy of the Antenna object + antenna = antenna.description + if isinstance(antenna, str): + # Create a temporary Antenna object and pilfer its internals (if needed) + antenna = Antenna.from_description(antenna) + name = name if name else antenna.name + diameter = diameter if diameter else antenna.diameter + delay_model = delay_model if delay_model else antenna.delay_model + pointing_model = pointing_model if pointing_model else antenna.pointing_model + beamwidth = beamwidth if beamwidth else antenna.beamwidth + antenna = antenna.ref_location + + if ',' in name: + raise ValueError(f"Antenna name '{name}' may not contain commas") self.name = name self.diameter = float(diameter) self.delay_model = DelayModel(delay_model) self.pointing_model = PointingModel(pointing_model) self.beamwidth = float(beamwidth) - - # Set up reference earth location first - if type(latitude) == str: - lat = Latitude(latitude, unit=u.deg) - else: - lat = Latitude(latitude, unit=u.rad) - if type(longitude) == str: - lon = Longitude(longitude, unit=u.deg) - else: - lon = Longitude(longitude, unit=u.rad) - if isinstance(altitude, u.Quantity): - height = altitude - else: - height = float(altitude) * u.meter - # Disable astropy's built-in refraction model. - self.ref_pressure = 0.0 * u.bar - self.ref_location = EarthLocation(lat=lat, lon=lon, height=height) - - self.ref_position_wgs84 = (self.ref_location.lat.rad, - self.ref_location.lon.rad, - self.ref_location.height.to(u.meter).value) - + if self.beamwidth <= 0: + self.beamwidth = DEFAULT_BEAMWIDTH + self.ref_location = self.location = antenna if self.delay_model: - dm = self.delay_model - self.position_enu = (dm['POS_E'], dm['POS_N'], dm['POS_U']) - # Convert ENU offset to ECEF coordinates of antenna, and then to WGS84 coordinates - self.position_ecef = enu_to_ecef(self.ref_location.lat.rad, - self.ref_location.lon.rad, - self.ref_location.height.to(u.meter).value, - *self.position_enu) - lat, lon, elevation = ecef_to_lla(*self.position_ecef) - lat = Latitude(lat, unit=u.rad) - lon = Longitude(lon, unit=u.rad) - self.pressure = 0.0 - self.location = EarthLocation(lat=lat, lon=lon, height=height) - self.position_wgs84 = (self.location.lat.rad, - self.location.lon.rad, - self.location.height.to(u.meter).value) - else: - self.location = self.ref_location - self.pressure = self.ref_pressure - self.position_enu = (0.0, 0.0, 0.0) - self.position_wgs84 = lat, lon, alt = (self.location.lat.rad, - self.location.lon.rad, - self.location.height.to(u.meter).value) - self.position_ecef = enu_to_ecef(lat, lon, alt, *self.position_enu) + # Convert ENU offset to ECEF coordinates of antenna + xyz = enu_to_ecef(*self.ref_position_wgs84, *self.position_enu) + self.location = EarthLocation.from_geocentric(*xyz, unit=u.m) def __str__(self): """Verbose human-friendly string representation of antenna object.""" - if np.any(self.position_enu): - return "%s: %d-m dish at ENU offset %s m from lat %s, lon %s, alt %s m" % \ - tuple([self.name, self.diameter, np.array(self.position_enu)] - + list(self.ref_position_wgs84)) - else: - return "%s: %d-m dish at lat %s, lon %s, alt %s m" % \ - tuple([self.name, self.diameter] + list(self.position_wgs84)) + return self.description def __repr__(self): """Short human-friendly string representation of antenna object.""" @@ -272,17 +212,38 @@ def __hash__(self): """Base hash on description string, just like equality operator.""" return hash(self.description) + @property + def ref_position_wgs84(self): + return (self.ref_location.lat.rad, + self.ref_location.lon.rad, + self.ref_location.height.to_value(u.m)) + + @property + def position_wgs84(self): + return (self.location.lat.rad, + self.location.lon.rad, + self.location.height.to_value(u.m)) + + @property + def position_enu(self): + dm = self.delay_model + return (dm['POS_E'], dm['POS_N'], dm['POS_U']) + + @property + def position_ecef(self): + return tuple(self.location.itrs.cartesian.xyz.to_value(u.m)) + @property def description(self): """Complete string representation of antenna object, sufficient to reconstruct it.""" # These fields are used to build up the antenna description string fields = [self.name] - location = self.ref_location if self.delay_model else self.location + location = self.ref_location fields += [location.lat.to_string(sep=':', unit=u.deg)] fields += [location.lon.to_string(sep=':', unit=u.deg)] # State height to nearest micrometre (way overkill) to get rid of numerical fluff, # using poor man's {:.6g} that avoids scientific notation for very small heights - height_m = location.height.to(u.meter).value + height_m = location.height.to_value(u.m) fields += ['{:.6f}'.format(height_m).rstrip('0').rstrip('.')] fields += [str(self.diameter)] fields += [self.delay_model.description] @@ -290,9 +251,25 @@ def description(self): fields += [str(self.beamwidth)] return ', '.join(fields) - def format_katcp(self): - """String representation if object is passed as parameter to KATCP command.""" - return self.description + @classmethod + def from_description(cls, description): + """Construct antenna object from description string.""" + errmsg_prefix = f"Antenna description string '{description}' " + if not description: + raise ValueError(errmsg_prefix + 'empty') + try: + description.encode('ascii') + except UnicodeError: + raise ValueError(errmsg_prefix + 'contains non-ASCII characters') + # Split description string on commas + fields = [s.strip() for s in description.split(',')] + # Extract required fields + if len(fields) < 4: + raise ValueError(errmsg_prefix + 'has fewer than four fields') + name, latitude, longitude, altitude = fields[:4] + # Construct Earth location from WGS84 coordinates + location = EarthLocation(lat=to_angle(latitude), lon=to_angle(longitude), height=altitude) + return cls(location, name, *fields[4:8]) def baseline_toward(self, antenna2): """Baseline vector pointing toward second antenna, in ENU coordinates. @@ -315,8 +292,7 @@ def baseline_toward(self, antenna2): if self.position_wgs84 == antenna2.ref_position_wgs84: return antenna2.position_enu else: - lat, lon, alt = self.position_wgs84 - return ecef_to_enu(lat, lon, alt, *lla_to_ecef(*antenna2.position_wgs84)) + return ecef_to_enu(*self.position_wgs84, *lla_to_ecef(*antenna2.position_wgs84)) def local_sidereal_time(self, timestamp=None): """Calculate local apparent sidereal time at antenna for timestamp(s). @@ -348,5 +324,4 @@ def array_reference_antenna(self, name='array'): intended to be used only for its position and does not correspond to a physical antenna. """ - pos = self.ref_position_wgs84 if self.delay_model else self.position_wgs84 - return Antenna(name, pos[0], pos[1], pos[2], self.diameter, beamwidth=self.beamwidth) + return Antenna(self.ref_location, name) diff --git a/katpoint/test/test_antenna.py b/katpoint/test/test_antenna.py index c655af5..3cb016e 100644 --- a/katpoint/test/test_antenna.py +++ b/katpoint/test/test_antenna.py @@ -21,6 +21,8 @@ import pytest import numpy as np +import astropy.units as u +from astropy.coordinates import EarthLocation import katpoint @@ -34,6 +36,7 @@ 'FF1, -30:43:17.3, 21:24:38.5, 1038.0, 12.0, 18.4 -8.7 0.0', ('FF2, -30:43:17.3, 21:24:38.5, 1038.0, 12.0, 86.2 25.5 0.0, ' '-0:06:39.6 0 0 0 0 0 0:09:48.9, 1.16'), + ', -25:53:23.0, 27:41:03.0, 1406.1086, 15.0', # unnamed antenna ] ) def test_construct_valid_antenna(description): @@ -41,15 +44,13 @@ def test_construct_valid_antenna(description): # Normalise description string through one cycle to allow comparison reference_description = katpoint.Antenna(description).description test_antenna = katpoint.Antenna(reference_description) - assert test_antenna.description == reference_description, ( + assert str(test_antenna) == test_antenna.description == reference_description, ( 'Antenna description differs from original string') - assert test_antenna.description == test_antenna.format_katcp(), ( - 'Antenna description differs from KATCP format') # Exercise repr() and str() print('{!r} {}'.format(test_antenna, test_antenna)) -@pytest.mark.parametrize("description", ['XDM, -25:53:23.05075, 27:41:03.0', '']) +@pytest.mark.parametrize("description", ['XDM, -25:53:23.05075, 27:41:03.0', '', '\U0001F602']) def test_construct_invalid_antenna(description): """Test construction of invalid antennas from strings.""" with pytest.raises(ValueError): @@ -57,15 +58,31 @@ def test_construct_invalid_antenna(description): def test_construct_antenna(): - """Test construction of antennas from strings and vice versa.""" - descr = katpoint.Antenna('XDM, -25:53:23.0, 27:41:03.0, 1406.1086, 15.0').description - assert descr == katpoint.Antenna(*descr.split(', ')).description + """Test various ways to construct and compare antennas.""" + a0 = katpoint.Antenna('XDM, -25:53:23.0, 27:41:03.0, 1406.1086, 15.0') + # Construct Antenna from Antenna + assert katpoint.Antenna(a0) == a0 + # Override some parameters + a0b = katpoint.Antenna(a0, name='bloop', beamwidth=3.14) + assert a0b.location == a0.location + assert a0b.name == 'bloop' + assert a0b.diameter == a0.diameter + assert a0b.delay_model == a0.delay_model + assert a0b.pointing_model == a0.pointing_model + assert a0b.beamwidth == 3.14 + # Construct Antenna from EarthLocation + descr = a0.description + fields = descr.split(', ') + name = fields[0] + location = EarthLocation.from_geodetic(lat=fields[1], lon=fields[2], height=fields[3]) + assert katpoint.Antenna(location, name, *fields[4:]).description == descr with pytest.raises(ValueError): - katpoint.Antenna(descr, *descr.split(', ')[1:]) + katpoint.Antenna(location, name + ', oops', *fields[4:]) # Check that description string updates when object is updated a1 = katpoint.Antenna('FF1, -30:43:17.3, 21:24:38.5, 1038.0, 12.0, 18.4 -8.7 0.0') a2 = katpoint.Antenna('FF2, -30:43:17.3, 21:24:38.5, 1038.0, 13.0, 18.4 -8.7 0.0, 0.1, 1.22') assert a1 != a2, 'Antennas should be inequal' + assert a1 < a2, 'Antenna a1 comes before a2 when sorted by description string' a1.name = 'FF2' a1.diameter = 13.0 a1.pointing_model = katpoint.PointingModel('0.1') @@ -81,6 +98,27 @@ def test_construct_antenna(): pytest.fail('Antenna object not hashable') +def test_coordinates(): + """Test coordinates associated with antenna location.""" + lla = ('-30:42:39.8', '21:26:38.0', '1086.6') + enu = (-8.264, -207.29, 8.5965) + ant = katpoint.Antenna(f"m000, {', '.join(lla)}, 13.5, {' '.join(str(c) for c in enu)}") + ref_location = EarthLocation.from_geodetic(lat=lla[0], lon=lla[1], height=lla[2]) + assert ant.ref_location == ref_location + assert ant.position_enu == enu + ant0 = ant.array_reference_antenna() + assert ant0.location == ref_location + assert ant0.position_ecef == tuple(ref_location.itrs.cartesian.xyz.to_value(u.m)) + assert ant0.position_wgs84 == (ref_location.lat.to_value(u.rad), + ref_location.lon.to_value(u.rad), + ref_location.height.to_value(u.m)) + assert ant0.baseline_toward(ant) == enu + reverse_bl = ant.baseline_toward(ant0) + assert reverse_bl[0] == pytest.approx(-enu[0], abs=5e-4) + assert reverse_bl[1] == pytest.approx(-enu[1], abs=5e-4) + assert reverse_bl[2] == pytest.approx(-enu[2], abs=1e-2) + + def test_local_sidereal_time(): """Test sidereal time and the use of date/time strings vs floats as timestamps.""" ant = katpoint.Antenna('XDM, -25:53:23.0, 27:41:03.0, 1406.1086, 15.0') @@ -99,4 +137,4 @@ def test_array_reference_antenna(): ant = katpoint.Antenna('FF2, -30:43:17.3, 21:24:38.5, 1038.0, 12.0, 86.2 25.5 0.0, ' '-0:06:39.6 0 0 0 0 0 0:09:48.9, 1.16') ref_ant = ant.array_reference_antenna() - assert ref_ant.description == 'array, -30:43:17.3, 21:24:38.5, 1038, 12.0, , , 1.16' + assert ref_ant.description == 'array, -30:43:17.3, 21:24:38.5, 1038, 0.0, , , 1.22' From 4e387fb36bdb8af925d3e752d87a5d2c4f66372b Mon Sep 17 00:00:00 2001 From: Ludwig Schwardt Date: Tue, 15 Sep 2020 15:54:32 +0200 Subject: [PATCH 115/122] Fix docstrings to reflect new reality --- katpoint/antenna.py | 71 ++++++++++++++------------------------------- 1 file changed, 22 insertions(+), 49 deletions(-) diff --git a/katpoint/antenna.py b/katpoint/antenna.py index 15437bc..0298cc5 100644 --- a/katpoint/antenna.py +++ b/katpoint/antenna.py @@ -42,15 +42,14 @@ class Antenna: """An antenna that can point at a target. - This is a wrapper around an Astropy earth location - adds a dish diameter and other parameters related to pointing and delay - calculations. + This is a wrapper around an Astropy `EarthLocation` that adds a dish + diameter and other parameters related to pointing and delay calculations. + It has two variants: a stand-alone single dish, or an antenna that is part - of an array. The first variant is initialised with the antenna location in - WGS84 (lat-lon-alt) form, while the second variant is initialised with the - array reference location in WGS84 form and an ENU (east-north-up) offset - for the specific antenna which also doubles as the first part of a broader - delay model for the antenna. + of an array. The first variant is initialised with the antenna location, + while the second variant is initialised with the array reference location + and an ENU (east-north-up) offset for the specific antenna which also + doubles as the first part of a broader delay model for the antenna. Additionally, a diameter, a pointing model and a beamwidth factor may be specified. These parameters are collected for convenience, and the pointing @@ -71,7 +70,8 @@ class Antenna: floating-point number. Any empty fields at the end of the description string may be omitted, as - they will be replaced by defaults. The first four fields are required. + they will be replaced by defaults. The first four fields are required + (but the name may be an empty string). Here are some examples of description strings:: @@ -86,14 +86,10 @@ class Antenna: Parameters ---------- - name : string or :class:`Antenna` object - Name of antenna, or full description string or existing antenna object - latitude : string or float, optional - Geodetic latitude, either in 'D:M:S' string format or float in radians - longitude : string or float, optional - Longitude, either in 'D:M:S' string format or a float in radians - altitude : string or float, optional - Altitude above WGS84 geoid, in metres + antenna : :class:`~astropy.coordinates.EarthLocation`, str or :class:`Antenna` + A location on Earth, a full description string or existing antenna object + name : string, optional + Name of antenna (may be empty but may not contain commas) diameter : string or float, optional Dish diameter, in metres delay_model : :class:`DelayModel` object or equivalent, optional @@ -113,45 +109,18 @@ class Antenna: circular dish to 1.22 for a Gaussian-tapered circular dish (the default). - Arguments - --------- - position_enu : tuple of 3 floats - East-North-Up offset from WGS84 reference position, in metres - position_wgs84 : tuple of 3 floats - WGS84 position of antenna (latitude and longitude in radians, and - altitude in metres) - position_ecef : tuple of 3 floats - ECEF (Earth-centred Earth-fixed) position of antenna (in metres) - ref_position_wgs84 : tuple of 3 floats - WGS84 reference position (latitude and longitude in radians, and - altitude in metres) - location :class:`astropy.coordinates.EarthLocation` object + Attributes + ---------- + location :class:`~astropy.coordinates.EarthLocation` Underlying object used for pointing calculations - ref_location :class:`astropy.coordinates.EarthLocation` object + ref_location :class:`~astropy.coordinates.EarthLocation` Array reference location for antenna in an array (same as - *location* for a stand-alone antenna) + `location` for a stand-alone antenna) Raises ------ ValueError If description string has wrong format or parameters are incorrect - - Notes - ----- - The only reason for the existence of - *ref_observer* is that it is a nice container for the reference latitude, - longitude and altitude. - - It is a bad idea to edit the coordinates of the antenna in-place, as the - various position tuples will not be updated - reconstruct a new antenna - object instead. - - Also note that the description string of the new Antenna could differ from - the original description string if the original string had higher precision - in its latitude and longitude coordinates than what ephem can handle - internally. Generally the latitude and longitude should be specified up to - 0.1 arcsecond precision, while altitude should be in metres and East, North - and Up offsets are generally specified up to millimetres. """ def __init__(self, antenna, name='', diameter=0.0, delay_model=None, @@ -214,23 +183,27 @@ def __hash__(self): @property def ref_position_wgs84(self): + """WGS84 reference position (latitude and longitude in radians, and altitude in metres)""" return (self.ref_location.lat.rad, self.ref_location.lon.rad, self.ref_location.height.to_value(u.m)) @property def position_wgs84(self): + """WGS84 position (latitude and longitude in radians, and altitude in metres).""" return (self.location.lat.rad, self.location.lon.rad, self.location.height.to_value(u.m)) @property def position_enu(self): + """East-North-Up offset from WGS84 reference position, in metres.""" dm = self.delay_model return (dm['POS_E'], dm['POS_N'], dm['POS_U']) @property def position_ecef(self): + """ECEF (Earth-centred Earth-fixed) position of antenna (in metres).""" return tuple(self.location.itrs.cartesian.xyz.to_value(u.m)) @property From 39a44e625f908fd968468db0cc6bf2a2a891ca3e Mon Sep 17 00:00:00 2001 From: Ludwig Schwardt Date: Tue, 15 Sep 2020 21:43:26 +0200 Subject: [PATCH 116/122] Improve round-tripping of antenna descriptions Ensure that antenna locations extracted from description strings are within 1e-6 metres of their original positions. Boost latitude and longitude precision to 8 digits but still cull redundant zeros as a simple {:.8g} alternative. Do the same for height and diameter, to 6 digits precision. This should be sufficient for most radio astronomy applications. The ALMA telescope operates on wavelengths down to 300 microns, while their antenna positions are updated once they deviate by more than 50 microns. This addresses MeerKAT JIRA ticket SR-306. --- katpoint/antenna.py | 6 ++++-- katpoint/test/test_antenna.py | 35 +++++++++++++++++++++++++++++++++-- 2 files changed, 37 insertions(+), 4 deletions(-) diff --git a/katpoint/antenna.py b/katpoint/antenna.py index 0298cc5..09352ce 100644 --- a/katpoint/antenna.py +++ b/katpoint/antenna.py @@ -212,8 +212,10 @@ def description(self): # These fields are used to build up the antenna description string fields = [self.name] location = self.ref_location - fields += [location.lat.to_string(sep=':', unit=u.deg)] - fields += [location.lon.to_string(sep=':', unit=u.deg)] + lat_str = location.lat.to_string(sep=':', unit=u.deg, precision=8) + lon_str = location.lon.to_string(sep=':', unit=u.deg, precision=8) + # Strip off redundant zeros from coordinate strings (similar to {:.8g}) + fields += [lat_str.rstrip('0').rstrip('.'), lon_str.rstrip('0').rstrip('.')] # State height to nearest micrometre (way overkill) to get rid of numerical fluff, # using poor man's {:.6g} that avoids scientific notation for very small heights height_m = location.height.to_value(u.m) diff --git a/katpoint/test/test_antenna.py b/katpoint/test/test_antenna.py index 3cb016e..08da7c8 100644 --- a/katpoint/test/test_antenna.py +++ b/katpoint/test/test_antenna.py @@ -46,8 +46,6 @@ def test_construct_valid_antenna(description): test_antenna = katpoint.Antenna(reference_description) assert str(test_antenna) == test_antenna.description == reference_description, ( 'Antenna description differs from original string') - # Exercise repr() and str() - print('{!r} {}'.format(test_antenna, test_antenna)) @pytest.mark.parametrize("description", ['XDM, -25:53:23.05075, 27:41:03.0', '', '\U0001F602']) @@ -62,6 +60,8 @@ def test_construct_antenna(): a0 = katpoint.Antenna('XDM, -25:53:23.0, 27:41:03.0, 1406.1086, 15.0') # Construct Antenna from Antenna assert katpoint.Antenna(a0) == a0 + # Exercise repr() and str() + print('{!r} {}'.format(a0, a0)) # Override some parameters a0b = katpoint.Antenna(a0, name='bloop', beamwidth=3.14) assert a0b.location == a0.location @@ -138,3 +138,34 @@ def test_array_reference_antenna(): '-0:06:39.6 0 0 0 0 0 0:09:48.9, 1.16') ref_ant = ant.array_reference_antenna() assert ref_ant.description == 'array, -30:43:17.3, 21:24:38.5, 1038, 0.0, , , 1.22' + + +@pytest.mark.parametrize( + "description", + [ + 'FF2, -30:43:17.3, 21:24:38.5, 1038, 12.0, 86.2 25.5, , 1.22', + 'FF2, -30:43:17.34567, 21:24:38.56723, 1038.1086, 12.0, 86.2 25.5, , 1.22', + 'FF2, -30:43:17.12345678, 21:24:38.12345678, 1038.123456, 12.0, 86.2 25.5, , 1.22', + 'FF2, -30:43:17.3, 21:24:38.5, 1038, 12.0, 86.123456 25.123456, , 1.22', + ] +) +def test_description_round_trip(description): + assert katpoint.Antenna(description).description == description + + +@pytest.mark.parametrize( + "location", + [ + # The canonical MeerKAT array centre in ITRS to nearest millimetre + EarthLocation.from_geocentric(5109360.133, 2006852.586, -3238948.127, unit=u.m), + # The canonical MeerKAT array centre in WGS84 + EarthLocation.from_geodetic('-30:42:39.8', '21:26:38.0', '1086.6'), + # The WGS84 array centre in XYZ format (0.5 mm difference...) + EarthLocation.from_geocentric(5109360.13332123, 2006852.58604291, -3238948.12747888, unit=u.m), + ] +) +def test_location_round_trip(location): + xyz = location.itrs.cartesian + descr = katpoint.Antenna(location).description + xyz2 = katpoint.Antenna(descr).location.itrs.cartesian + assert (xyz2 - xyz).norm() < 1 * u.micrometer From fd17376b9960f7ba3ebf205d36a66a238f2cd6e7 Mon Sep 17 00:00:00 2001 From: Ludwig Schwardt Date: Wed, 16 Sep 2020 12:26:15 +0200 Subject: [PATCH 117/122] Turn diameter into a Quantity Also add a _strip_zeros helper function that performs a poor man's %g. It avoids scientific notation but still strips off redundant zeros at the end of a numerical string. --- katpoint/antenna.py | 19 +++++++++++-------- katpoint/test/test_antenna.py | 12 ++++++------ 2 files changed, 17 insertions(+), 14 deletions(-) diff --git a/katpoint/antenna.py b/katpoint/antenna.py index 09352ce..5bae510 100644 --- a/katpoint/antenna.py +++ b/katpoint/antenna.py @@ -34,6 +34,11 @@ # FWHM beamwidth of a Gaussian-tapered circular dish, as a multiple of lambda / D DEFAULT_BEAMWIDTH = 1.22 + +def _strip_zeros(number, format_str='{}'): + """An alternate {:g} that avoids scientific notation but adjusts precision.""" + return format_str.format(number).rstrip('0').rstrip('.') + # -------------------------------------------------------------------------------------------------- # --- CLASS : Antenna # -------------------------------------------------------------------------------------------------- @@ -90,7 +95,7 @@ class Antenna: A location on Earth, a full description string or existing antenna object name : string, optional Name of antenna (may be empty but may not contain commas) - diameter : string or float, optional + diameter : :class:`~astropy.units.Quantity` or string or float, optional Dish diameter, in metres delay_model : :class:`DelayModel` object or equivalent, optional Delay model for antenna, either as a direct object, a file-like object @@ -141,7 +146,7 @@ def __init__(self, antenna, name='', diameter=0.0, delay_model=None, if ',' in name: raise ValueError(f"Antenna name '{name}' may not contain commas") self.name = name - self.diameter = float(diameter) + self.diameter = diameter << u.m self.delay_model = DelayModel(delay_model) self.pointing_model = PointingModel(pointing_model) self.beamwidth = float(beamwidth) @@ -212,15 +217,13 @@ def description(self): # These fields are used to build up the antenna description string fields = [self.name] location = self.ref_location - lat_str = location.lat.to_string(sep=':', unit=u.deg, precision=8) - lon_str = location.lon.to_string(sep=':', unit=u.deg, precision=8) # Strip off redundant zeros from coordinate strings (similar to {:.8g}) - fields += [lat_str.rstrip('0').rstrip('.'), lon_str.rstrip('0').rstrip('.')] + fields += [_strip_zeros(location.lat.to_string(sep=':', unit=u.deg, precision=8))] + fields += [_strip_zeros(location.lon.to_string(sep=':', unit=u.deg, precision=8))] # State height to nearest micrometre (way overkill) to get rid of numerical fluff, # using poor man's {:.6g} that avoids scientific notation for very small heights - height_m = location.height.to_value(u.m) - fields += ['{:.6f}'.format(height_m).rstrip('0').rstrip('.')] - fields += [str(self.diameter)] + fields += [_strip_zeros(location.height.to_value(u.m), '{:.6f}')] + fields += [_strip_zeros(self.diameter.to_value(u.m), '{:.6f}')] fields += [self.delay_model.description] fields += [self.pointing_model.description] fields += [str(self.beamwidth)] diff --git a/katpoint/test/test_antenna.py b/katpoint/test/test_antenna.py index 08da7c8..4635ea3 100644 --- a/katpoint/test/test_antenna.py +++ b/katpoint/test/test_antenna.py @@ -84,7 +84,7 @@ def test_construct_antenna(): assert a1 != a2, 'Antennas should be inequal' assert a1 < a2, 'Antenna a1 comes before a2 when sorted by description string' a1.name = 'FF2' - a1.diameter = 13.0 + a1.diameter = 13.0 * u.m a1.pointing_model = katpoint.PointingModel('0.1') a1.beamwidth = 1.22 assert a1.description == a2.description, 'Antenna description string not updated' @@ -137,16 +137,16 @@ def test_array_reference_antenna(): ant = katpoint.Antenna('FF2, -30:43:17.3, 21:24:38.5, 1038.0, 12.0, 86.2 25.5 0.0, ' '-0:06:39.6 0 0 0 0 0 0:09:48.9, 1.16') ref_ant = ant.array_reference_antenna() - assert ref_ant.description == 'array, -30:43:17.3, 21:24:38.5, 1038, 0.0, , , 1.22' + assert ref_ant.description == 'array, -30:43:17.3, 21:24:38.5, 1038, 0, , , 1.22' @pytest.mark.parametrize( "description", [ - 'FF2, -30:43:17.3, 21:24:38.5, 1038, 12.0, 86.2 25.5, , 1.22', - 'FF2, -30:43:17.34567, 21:24:38.56723, 1038.1086, 12.0, 86.2 25.5, , 1.22', - 'FF2, -30:43:17.12345678, 21:24:38.12345678, 1038.123456, 12.0, 86.2 25.5, , 1.22', - 'FF2, -30:43:17.3, 21:24:38.5, 1038, 12.0, 86.123456 25.123456, , 1.22', + 'FF2, -30:43:17.3, 21:24:38.5, 1038, 12, 86.2 25.5, , 1.22', + 'FF2, -30:43:17.34567, 21:24:38.56723, 1038.1086, 12, 86.2 25.5, , 1.22', + 'FF2, -30:43:17.12345678, 21:24:38.12345678, 1038.123456, 12, 86.2 25.5, , 1.22', + 'FF2, -30:43:17.3, 21:24:38.5, 1038, 12, 86.123456 25.123456, , 1.22', ] ) def test_description_round_trip(description): From ac2e4442580fff8df60dc917589a0020bb742f0a Mon Sep 17 00:00:00 2001 From: Ludwig Schwardt Date: Thu, 17 Sep 2020 15:24:33 +0200 Subject: [PATCH 118/122] Improve error message --- katpoint/antenna.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/katpoint/antenna.py b/katpoint/antenna.py index 5bae510..7f5df91 100644 --- a/katpoint/antenna.py +++ b/katpoint/antenna.py @@ -234,7 +234,7 @@ def from_description(cls, description): """Construct antenna object from description string.""" errmsg_prefix = f"Antenna description string '{description}' " if not description: - raise ValueError(errmsg_prefix + 'empty') + raise ValueError(errmsg_prefix + 'is empty') try: description.encode('ascii') except UnicodeError: From d364adcd37844035b09dc59b86e8634b3911873f Mon Sep 17 00:00:00 2001 From: Ludwig Schwardt Date: Tue, 22 Sep 2020 12:10:43 +0200 Subject: [PATCH 119/122] Rework default antenna parameters It is now possible to override a non-default parameter value with a default one. For example, you can set the antenna name of an existing Antenna to ''. Also highlight that an existing Antenna object or description string effectively provides default values for the antenna parameters, which can still be overridden during initialisation. --- katpoint/antenna.py | 35 ++++++++++++++++++++--------------- katpoint/test/test_antenna.py | 14 ++++++++++++-- 2 files changed, 32 insertions(+), 17 deletions(-) diff --git a/katpoint/antenna.py b/katpoint/antenna.py index 7f5df91..e9fec86 100644 --- a/katpoint/antenna.py +++ b/katpoint/antenna.py @@ -21,6 +21,8 @@ and other parameters that affect pointing and delay calculations. """ +from types import SimpleNamespace + import astropy.units as u from astropy.coordinates import EarthLocation @@ -31,8 +33,8 @@ from .delay import DelayModel -# FWHM beamwidth of a Gaussian-tapered circular dish, as a multiple of lambda / D -DEFAULT_BEAMWIDTH = 1.22 +# Singleton that identifies default antenna parameters +_DEFAULT = object() def _strip_zeros(number, format_str='{}'): @@ -92,7 +94,9 @@ class Antenna: Parameters ---------- antenna : :class:`~astropy.coordinates.EarthLocation`, str or :class:`Antenna` - A location on Earth, a full description string or existing antenna object + A location on Earth, a full description string or existing Antenna object. + The parameters in the description string or existing Antenna can still + be overridden by providing additional parameters after `antenna`. name : string, optional Name of antenna (may be empty but may not contain commas) diameter : :class:`~astropy.units.Quantity` or string or float, optional @@ -128,20 +132,23 @@ class Antenna: If description string has wrong format or parameters are incorrect """ - def __init__(self, antenna, name='', diameter=0.0, delay_model=None, - pointing_model=None, beamwidth=0.0): + def __init__(self, antenna, name=_DEFAULT, diameter=_DEFAULT, delay_model=_DEFAULT, + pointing_model=_DEFAULT, beamwidth=_DEFAULT): + default = SimpleNamespace(name='', diameter=0.0, delay_model=None, + pointing_model=None, beamwidth=1.22) if isinstance(antenna, Antenna): # A simple way to make a deep copy of the Antenna object antenna = antenna.description if isinstance(antenna, str): - # Create a temporary Antenna object and pilfer its internals (if needed) - antenna = Antenna.from_description(antenna) - name = name if name else antenna.name - diameter = diameter if diameter else antenna.diameter - delay_model = delay_model if delay_model else antenna.delay_model - pointing_model = pointing_model if pointing_model else antenna.pointing_model - beamwidth = beamwidth if beamwidth else antenna.beamwidth - antenna = antenna.ref_location + # Create a temporary Antenna object to serve up default parameters instead + default = Antenna.from_description(antenna) + antenna = default.ref_location + + name = default.name if name is _DEFAULT else name + diameter = default.diameter if diameter is _DEFAULT else diameter + delay_model = default.delay_model if delay_model is _DEFAULT else delay_model + pointing_model = default.pointing_model if pointing_model is _DEFAULT else pointing_model + beamwidth = default.beamwidth if beamwidth is _DEFAULT else beamwidth if ',' in name: raise ValueError(f"Antenna name '{name}' may not contain commas") @@ -150,8 +157,6 @@ def __init__(self, antenna, name='', diameter=0.0, delay_model=None, self.delay_model = DelayModel(delay_model) self.pointing_model = PointingModel(pointing_model) self.beamwidth = float(beamwidth) - if self.beamwidth <= 0: - self.beamwidth = DEFAULT_BEAMWIDTH self.ref_location = self.location = antenna if self.delay_model: # Convert ENU offset to ECEF coordinates of antenna diff --git a/katpoint/test/test_antenna.py b/katpoint/test/test_antenna.py index 4635ea3..1668dfb 100644 --- a/katpoint/test/test_antenna.py +++ b/katpoint/test/test_antenna.py @@ -57,19 +57,29 @@ def test_construct_invalid_antenna(description): def test_construct_antenna(): """Test various ways to construct and compare antennas.""" - a0 = katpoint.Antenna('XDM, -25:53:23.0, 27:41:03.0, 1406.1086, 15.0') + a0 = katpoint.Antenna('XDM, -25:53:23.0, 27:41:03.0, 1406.1086, 15.0, 1 2 3, 1 2 3, 1.14') # Construct Antenna from Antenna assert katpoint.Antenna(a0) == a0 + # Construct Antenna from description string + assert katpoint.Antenna(a0.description) == a0 # Exercise repr() and str() print('{!r} {}'.format(a0, a0)) # Override some parameters - a0b = katpoint.Antenna(a0, name='bloop', beamwidth=3.14) + a0b = katpoint.Antenna(a0.description, name='bloop', beamwidth=3.14) assert a0b.location == a0.location assert a0b.name == 'bloop' assert a0b.diameter == a0.diameter assert a0b.delay_model == a0.delay_model assert a0b.pointing_model == a0.pointing_model assert a0b.beamwidth == 3.14 + # Check that we can also replace non-default parameters with defaults + a0c = katpoint.Antenna(a0, name='', diameter=0.0, delay_model=None, pointing_model=None) + assert a0c.location == a0.ref_location + assert a0c.name == '' + assert a0c.diameter == 0.0 + assert not a0c.delay_model + assert not a0c.pointing_model + assert a0c.beamwidth == a0.beamwidth # Construct Antenna from EarthLocation descr = a0.description fields = descr.split(', ') From f6762a24c4ef589f9c4acb525d6b230a19b4dd73 Mon Sep 17 00:00:00 2001 From: Ludwig Schwardt Date: Tue, 22 Sep 2020 12:39:21 +0200 Subject: [PATCH 120/122] Minor MR fixes - Fix repr(): the diameter already has a unit. - Rework antenna comparisons: no need for __ne__ and add total_ordering. - Test the rest of the antenna comparisons. - Reword assertion failure in unit test. --- katpoint/antenna.py | 10 ++++------ katpoint/test/test_antenna.py | 5 ++++- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/katpoint/antenna.py b/katpoint/antenna.py index e9fec86..c5f59d0 100644 --- a/katpoint/antenna.py +++ b/katpoint/antenna.py @@ -21,6 +21,7 @@ and other parameters that affect pointing and delay calculations. """ +import functools from types import SimpleNamespace import astropy.units as u @@ -46,6 +47,7 @@ def _strip_zeros(number, format_str='{}'): # -------------------------------------------------------------------------------------------------- +@functools.total_ordering class Antenna: """An antenna that can point at a target. @@ -164,12 +166,12 @@ def __init__(self, antenna, name=_DEFAULT, diameter=_DEFAULT, delay_model=_DEFAU self.location = EarthLocation.from_geocentric(*xyz, unit=u.m) def __str__(self): - """Verbose human-friendly string representation of antenna object.""" + """Complete string representation of antenna object, sufficient to reconstruct it.""" return self.description def __repr__(self): """Short human-friendly string representation of antenna object.""" - return "" % (self.name, self.diameter, id(self)) + return f'' def __reduce__(self): """Custom pickling routine based on description string.""" @@ -179,10 +181,6 @@ def __eq__(self, other): """Equality comparison operator.""" return self.description == (other.description if isinstance(other, Antenna) else other) - def __ne__(self, other): - """Inequality comparison operator.""" - return not (self == other) - def __lt__(self, other): """Less-than comparison operator (needed for sorting and np.unique).""" return self.description < (other.description if isinstance(other, Antenna) else other) diff --git a/katpoint/test/test_antenna.py b/katpoint/test/test_antenna.py index 1668dfb..7608560 100644 --- a/katpoint/test/test_antenna.py +++ b/katpoint/test/test_antenna.py @@ -92,7 +92,10 @@ def test_construct_antenna(): a1 = katpoint.Antenna('FF1, -30:43:17.3, 21:24:38.5, 1038.0, 12.0, 18.4 -8.7 0.0') a2 = katpoint.Antenna('FF2, -30:43:17.3, 21:24:38.5, 1038.0, 13.0, 18.4 -8.7 0.0, 0.1, 1.22') assert a1 != a2, 'Antennas should be inequal' - assert a1 < a2, 'Antenna a1 comes before a2 when sorted by description string' + assert a1 < a2, 'Antenna a1 should come before a2 when sorted by description string' + assert a1 <= a2, 'Antenna a1 should come before a2 when sorted by description string' + assert a2 > a1, 'Antenna a1 should come before a2 when sorted by description string' + assert a2 >= a1, 'Antenna a1 should come before a2 when sorted by description string' a1.name = 'FF2' a1.diameter = 13.0 * u.m a1.pointing_model = katpoint.PointingModel('0.1') From 8f2fdd7bacd551f6fdee8c18fc067cae21e7c05b Mon Sep 17 00:00:00 2001 From: Ludwig Schwardt Date: Tue, 22 Sep 2020 13:10:12 +0200 Subject: [PATCH 121/122] Ensure that all geodetic coordinates are WGS84 This supports `EarthLocation`s with a different ellipsoid, e.g. WGS72. Previously the .lat, .lon and .height attributes referred to the native ellipsoid of the location, instead of the intended WGS84. Also convert to geodetic coordinates once, instead of thrice (once per attribute access). --- katpoint/antenna.py | 19 +++++++++---------- katpoint/test/test_antenna.py | 2 ++ 2 files changed, 11 insertions(+), 10 deletions(-) diff --git a/katpoint/antenna.py b/katpoint/antenna.py index c5f59d0..4c4447c 100644 --- a/katpoint/antenna.py +++ b/katpoint/antenna.py @@ -192,16 +192,14 @@ def __hash__(self): @property def ref_position_wgs84(self): """WGS84 reference position (latitude and longitude in radians, and altitude in metres)""" - return (self.ref_location.lat.rad, - self.ref_location.lon.rad, - self.ref_location.height.to_value(u.m)) + lon, lat, height = self.ref_location.to_geodetic(ellipsoid='WGS84') + return (lat.rad, lon.rad, height.to_value(u.m)) @property def position_wgs84(self): """WGS84 position (latitude and longitude in radians, and altitude in metres).""" - return (self.location.lat.rad, - self.location.lon.rad, - self.location.height.to_value(u.m)) + lon, lat, height = self.location.to_geodetic(ellipsoid='WGS84') + return (lat.rad, lon.rad, height.to_value(u.m)) @property def position_enu(self): @@ -219,13 +217,14 @@ def description(self): """Complete string representation of antenna object, sufficient to reconstruct it.""" # These fields are used to build up the antenna description string fields = [self.name] - location = self.ref_location + # Store `EarthLocation` as WGS84 coordinates + lon, lat, height = self.ref_location.to_geodetic(ellipsoid='WGS84') # Strip off redundant zeros from coordinate strings (similar to {:.8g}) - fields += [_strip_zeros(location.lat.to_string(sep=':', unit=u.deg, precision=8))] - fields += [_strip_zeros(location.lon.to_string(sep=':', unit=u.deg, precision=8))] + fields += [_strip_zeros(lat.to_string(sep=':', unit=u.deg, precision=8))] + fields += [_strip_zeros(lon.to_string(sep=':', unit=u.deg, precision=8))] # State height to nearest micrometre (way overkill) to get rid of numerical fluff, # using poor man's {:.6g} that avoids scientific notation for very small heights - fields += [_strip_zeros(location.height.to_value(u.m), '{:.6f}')] + fields += [_strip_zeros(height.to_value(u.m), '{:.6f}')] fields += [_strip_zeros(self.diameter.to_value(u.m), '{:.6f}')] fields += [self.delay_model.description] fields += [self.pointing_model.description] diff --git a/katpoint/test/test_antenna.py b/katpoint/test/test_antenna.py index 7608560..e6f4245 100644 --- a/katpoint/test/test_antenna.py +++ b/katpoint/test/test_antenna.py @@ -175,6 +175,8 @@ def test_description_round_trip(description): EarthLocation.from_geodetic('-30:42:39.8', '21:26:38.0', '1086.6'), # The WGS84 array centre in XYZ format (0.5 mm difference...) EarthLocation.from_geocentric(5109360.13332123, 2006852.58604291, -3238948.12747888, unit=u.m), + # Check a geodetic location based on a different ellipsoid (2 m difference from WGS84) + EarthLocation.from_geodetic('-30:42:39.8', '21:26:38.0', '1086.6', ellipsoid='WGS72'), ] ) def test_location_round_trip(location): From 08b4221ec5328ff4db2f68d90078d7d2a0c13fa9 Mon Sep 17 00:00:00 2001 From: Ludwig Schwardt Date: Wed, 23 Sep 2020 12:50:10 +0200 Subject: [PATCH 122/122] Ensure that construction from Antenna is exact Instead of turning Antennas into description strings during copy construction, rather go the other way around. This ensures that Antennas can be replicated exactly (and saves a roundtrip via descriptions). Shallow copies of internals should not be an issue, since `EarthLocation` coordinate attributes are immutable. Also simplify _strip_zeros. --- katpoint/antenna.py | 17 +++++++-------- katpoint/test/test_antenna.py | 39 ++++++++++++++++++++++------------- 2 files changed, 33 insertions(+), 23 deletions(-) diff --git a/katpoint/antenna.py b/katpoint/antenna.py index 4c4447c..9553862 100644 --- a/katpoint/antenna.py +++ b/katpoint/antenna.py @@ -38,9 +38,9 @@ _DEFAULT = object() -def _strip_zeros(number, format_str='{}'): - """An alternate {:g} that avoids scientific notation but adjusts precision.""" - return format_str.format(number).rstrip('0').rstrip('.') +def _strip_zeros(numerical_str): + """Remove trailing zeros and unnecessary decimal points from numerical strings.""" + return numerical_str.rstrip('0').rstrip('.') # -------------------------------------------------------------------------------------------------- # --- CLASS : Antenna @@ -138,12 +138,11 @@ def __init__(self, antenna, name=_DEFAULT, diameter=_DEFAULT, delay_model=_DEFAU pointing_model=_DEFAULT, beamwidth=_DEFAULT): default = SimpleNamespace(name='', diameter=0.0, delay_model=None, pointing_model=None, beamwidth=1.22) - if isinstance(antenna, Antenna): - # A simple way to make a deep copy of the Antenna object - antenna = antenna.description if isinstance(antenna, str): # Create a temporary Antenna object to serve up default parameters instead - default = Antenna.from_description(antenna) + antenna = Antenna.from_description(antenna) + if isinstance(antenna, Antenna): + default = antenna antenna = default.ref_location name = default.name if name is _DEFAULT else name @@ -224,8 +223,8 @@ def description(self): fields += [_strip_zeros(lon.to_string(sep=':', unit=u.deg, precision=8))] # State height to nearest micrometre (way overkill) to get rid of numerical fluff, # using poor man's {:.6g} that avoids scientific notation for very small heights - fields += [_strip_zeros(height.to_value(u.m), '{:.6f}')] - fields += [_strip_zeros(self.diameter.to_value(u.m), '{:.6f}')] + fields += [_strip_zeros('{:.6f}'.format(height.to_value(u.m)))] + fields += [_strip_zeros('{:.6f}'.format(self.diameter.to_value(u.m)))] fields += [self.delay_model.description] fields += [self.pointing_model.description] fields += [str(self.beamwidth)] diff --git a/katpoint/test/test_antenna.py b/katpoint/test/test_antenna.py index e6f4245..a9f372c 100644 --- a/katpoint/test/test_antenna.py +++ b/katpoint/test/test_antenna.py @@ -56,22 +56,29 @@ def test_construct_invalid_antenna(description): def test_construct_antenna(): - """Test various ways to construct and compare antennas.""" + """Test various ways to construct antennas, also with parameters that are overridden.""" a0 = katpoint.Antenna('XDM, -25:53:23.0, 27:41:03.0, 1406.1086, 15.0, 1 2 3, 1 2 3, 1.14') # Construct Antenna from Antenna assert katpoint.Antenna(a0) == a0 # Construct Antenna from description string assert katpoint.Antenna(a0.description) == a0 + # Construct Antenna from EarthLocation + fields = a0.description.split(', ') + name = fields[0] + location = EarthLocation.from_geodetic(lat=fields[1], lon=fields[2], height=fields[3]) + assert katpoint.Antenna(location, name, *fields[4:]).description == a0.description + with pytest.raises(ValueError): + katpoint.Antenna(location, name + ', oops', *fields[4:]) # Exercise repr() and str() print('{!r} {}'.format(a0, a0)) # Override some parameters - a0b = katpoint.Antenna(a0.description, name='bloop', beamwidth=3.14) + a0b = katpoint.Antenna(a0.description, name='bloop', beamwidth=np.pi) assert a0b.location == a0.location assert a0b.name == 'bloop' assert a0b.diameter == a0.diameter assert a0b.delay_model == a0.delay_model assert a0b.pointing_model == a0.pointing_model - assert a0b.beamwidth == 3.14 + assert a0b.beamwidth == np.pi # Check that we can also replace non-default parameters with defaults a0c = katpoint.Antenna(a0, name='', diameter=0.0, delay_model=None, pointing_model=None) assert a0c.location == a0.ref_location @@ -80,22 +87,26 @@ def test_construct_antenna(): assert not a0c.delay_model assert not a0c.pointing_model assert a0c.beamwidth == a0.beamwidth - # Construct Antenna from EarthLocation - descr = a0.description - fields = descr.split(', ') - name = fields[0] - location = EarthLocation.from_geodetic(lat=fields[1], lon=fields[2], height=fields[3]) - assert katpoint.Antenna(location, name, *fields[4:]).description == descr - with pytest.raises(ValueError): - katpoint.Antenna(location, name + ', oops', *fields[4:]) - # Check that description string updates when object is updated + # Check that construction from Antenna is exact + location = EarthLocation.from_geodetic(lat=np.pi, lon=np.pi, height=np.e) + a1 = katpoint.Antenna(location, name='pangolin', diameter=np.e, beamwidth=np.pi) + a2 = katpoint.Antenna(a1) + assert a1.location == a2.location == location + assert a1.name == a2.name + assert a1.diameter == a2.diameter + assert a1.beamwidth == a2.beamwidth + + +def test_compare_update_antenna(): + """Test various ways to compare and update antennas.""" a1 = katpoint.Antenna('FF1, -30:43:17.3, 21:24:38.5, 1038.0, 12.0, 18.4 -8.7 0.0') a2 = katpoint.Antenna('FF2, -30:43:17.3, 21:24:38.5, 1038.0, 13.0, 18.4 -8.7 0.0, 0.1, 1.22') assert a1 != a2, 'Antennas should be inequal' assert a1 < a2, 'Antenna a1 should come before a2 when sorted by description string' assert a1 <= a2, 'Antenna a1 should come before a2 when sorted by description string' - assert a2 > a1, 'Antenna a1 should come before a2 when sorted by description string' - assert a2 >= a1, 'Antenna a1 should come before a2 when sorted by description string' + assert a2 > a1, 'Antenna a2 should come after a1 when sorted by description string' + assert a2 >= a1, 'Antenna a2 should come after a1 when sorted by description string' + # Check that description string updates when object is updated a1.name = 'FF2' a1.diameter = 13.0 * u.m a1.pointing_model = katpoint.PointingModel('0.1')