From ec812e4f3821c227b603356d63454b7955191f08 Mon Sep 17 00:00:00 2001
From: jkuensem <jkuensem@physik.uni-bielefeld.de>
Date: Wed, 18 Nov 2020 17:38:40 +0100
Subject: [PATCH] TMSS-259: Change format according to Jan Davids suggestions.
 Change list input to tuples and add caching.

---
 SAS/TMSS/src/tmss/tmssapp/conversions.py | 58 +++++++++---------
 SAS/TMSS/src/tmss/tmssapp/views.py       | 76 ++++++++++--------------
 SAS/TMSS/test/t_conversions.py           | 54 +++++++++--------
 3 files changed, 90 insertions(+), 98 deletions(-)

diff --git a/SAS/TMSS/src/tmss/tmssapp/conversions.py b/SAS/TMSS/src/tmss/tmssapp/conversions.py
index 037d4b31d57..2c1e27da04d 100644
--- a/SAS/TMSS/src/tmss/tmssapp/conversions.py
+++ b/SAS/TMSS/src/tmss/tmssapp/conversions.py
@@ -6,7 +6,7 @@ from astropy.coordinates.earth import EarthLocation
 from astropy.coordinates import Angle, get_body
 from astroplan.observer import Observer
 import astropy.time
-
+from functools import lru_cache
 
 def create_astroplan_observer_for_station(station: str) -> Observer:
     '''
@@ -25,11 +25,12 @@ def create_astroplan_observer_for_station(station: str) -> Observer:
 SUN_SET_RISE_ANGLE_TO_HORIZON = Angle(10, unit=astropy.units.deg)
 SUN_SET_RISE_PRECISION = 15  # n_grid_points; higher is more precise but very costly; astropy defaults to 150, 15 seems to cause errors of typically one minute
 
-def timestamps_and_stations_to_sun_rise_and_set(timestamps: [datetime], stations: [str], angle_to_horizon: Angle=SUN_SET_RISE_ANGLE_TO_HORIZON) -> dict:
+@lru_cache(maxsize=256, typed=False)  # does not like lists, so use sets to allow caching
+def timestamps_and_stations_to_sun_rise_and_set(timestamps: tuple, stations: tuple, angle_to_horizon: Angle=SUN_SET_RISE_ANGLE_TO_HORIZON) -> dict:
     """
     compute sunrise, sunset, day and night of the given stations at the given timestamps
-    :param timestamps: list of datetimes, e.g. [datetime(2020, 1, 1), datetime(2020, 1, 2)]
-    :param stations: list of station names, e.g. ["CS002"]
+    :param timestamps: tuple of datetimes, e.g. (datetime(2020, 1, 1), datetime(2020, 1, 2))
+    :param stations: tuple of station names, e.g. ("CS002",)
     :return A dict that maps station names to a nested dict that contains lists of start and end times for sunrise, sunset, etc, on each requested date.
         E.g.
         {"CS002":
@@ -62,36 +63,35 @@ def timestamps_and_stations_to_sun_rise_and_set(timestamps: [datetime], stations
     return return_dict
 
 
-def coordinates_and_timestamps_to_separation_from_bodies(coords: [astropy.coordinates.SkyCoord], timestamps: [datetime], bodies: [str], stations: [str]) -> dict:
+@lru_cache(maxsize=256, typed=False)  # does not like lists, so use sets to allow caching
+def coordinates_and_timestamps_to_separation_from_bodies(angle1: float, angle2: float, direction_type: str, timestamps: tuple, bodies: tuple) -> dict:
     """
-    compute angular distances of the given sky coordinates from the given solar system bodies at the given timestamps and stations
-    :param coords: list of SkyCoord objects to measure separation
-    :param timestamps: list of datetimes, e.g. [datetime(2020, 1, 1, 15, 0, 0), datetime(2020, 1, 1, 16, 0, 0)]
-    :param stations: list of station names, e.g. ["CS002"]
-    :param bodies: list of solar system bodies, e.g. ['sun', 'moon', 'jupiter']
-    :return A dict that maps station names to a list with a dict for each given coordinate, which maps each body body to a list of separation angles for each given timestamp.
+    compute angular distances of the given sky coordinates from the given solar system bodies at the given timestamps (seen from LOFAR core)
+    :param angle1: first angle of celectial coordinates, e.g. RA
+    :param angle2: second angle of celectial coordinates, e.g. Dec
+    :param direction_type: direction_type of celectial coordinates, e.g. 'J2000'
+    :param timestamps: tuple of datetimes, e.g. (datetime(2020, 1, 1, 15, 0, 0), datetime(2020, 1, 1, 16, 0, 0))
+    :param bodies: tuple of solar system bodies, e.g. ('sun', 'moon', 'jupiter')
+    :return A dict that maps each body to a dict that maps the given timestamp to a separation angle from the given coordinate.
         E.g.
-        {"CS002":
-           [
-           {"sun": [Angle("0.7rad"), Angle("0.7rad")], "moon": [Angle("0.4rad"), Angle("0.4rad")], "jupiter": [Angle("2.7rad"), Angle("2.7rad")]},
-           {"sun": [Angle("0.8rad"), Angle("0.8rad")], "moon": [Angle("0.5rad"), Angle("0.5rad")], "jupiter": [Angle("2.6rad"), Angle("2.6rad")]}
-           ]
+        {
+           "sun": {datetime(2020, 1, 1, 6, 0, 0): Angle("0.7rad"), datetime(2020, 1, 1, 7, 0, 0): Angle("0.7rad")},
+           "moon": {datetime(2020, 1, 1, 6, 0, 0): Angle("0.4rad"), datetime(2020, 1, 1, 7, 0, 0): Angle("0.4rad")},
+           "jupiter": {datetime(2020, 1, 1, 6, 0, 0): Angle("2.7rad"), datetime(2020, 1, 1, 7, 0, 0): Angle("2.7rad")}
         }
-        # todo: not so sure about this data structure. SkyCoord is not hashable, so we cannot use it as a key.
-        # todo: We could provide coordinates in a string format and use that as keys, or limit to a single pointing to avoid confusion.
     """
+    if direction_type == "J2000":
+        coord = astropy.coordinates.SkyCoord(ra=angle1, dec=angle2, unit=astropy.units.deg)
+    else:
+        raise ValueError("Do not know how to convert direction_type=%s to SkyCoord" % direction_type)
     return_dict = {}
-    for station in stations:
-        for coord in coords:
-            body_angles = {}
-            for body in bodies:
-                location = create_astroplan_observer_for_station(station).location
-                for timestamp in timestamps:
-                    # get body coords at timestamp
-                    body_coord = get_body(body=body, time=astropy.time.Time(timestamp), location=location)
-                    angle = coord.separation(body_coord)
-                    body_angles.setdefault(body, []).append(angle)
-            return_dict.setdefault(station, []).append(body_angles)
+    for body in bodies:
+        location = create_astroplan_observer_for_station("CS002").location
+        for timestamp in timestamps:
+            # get body coords at timestamp
+            body_coord = get_body(body=body, time=astropy.time.Time(timestamp), location=location)
+            angle = coord.separation(body_coord)
+            return_dict.setdefault(body, {})[timestamp] = angle
     return return_dict
 
 
diff --git a/SAS/TMSS/src/tmss/tmssapp/views.py b/SAS/TMSS/src/tmss/tmssapp/views.py
index 7bee3fd449a..476bf65e275 100644
--- a/SAS/TMSS/src/tmss/tmssapp/views.py
+++ b/SAS/TMSS/src/tmss/tmssapp/views.py
@@ -160,31 +160,31 @@ def get_sun_rise_and_set(request):
     timestamps = request.GET.get('timestamps', None)
     stations = request.GET.get('stations', None)
     if timestamps is None:
-        timestamps = [datetime.utcnow()]
+        timestamps = (datetime.utcnow(),)
     else:
         timestamps = timestamps.split(',')
-        timestamps = [dateutil.parser.parse(timestamp) for timestamp in timestamps]  #  isot to datetime
+        timestamps = tuple([dateutil.parser.parse(timestamp) for timestamp in timestamps])  #  isot to datetime
     if stations is None:
-        stations = ["CS002"]
+        stations = ("CS002",)
     else:
-        stations = stations.split(',')
+        stations = tuple(stations.split(','))
 
     # todo: to improve speed for the frontend, we should probably precompute/cache these and return those (where available), to revisit after constraint table / TMSS-190 is done
     return JsonResponse(timestamps_and_stations_to_sun_rise_and_set(timestamps, stations))
 
+
 @permission_classes([AllowAny])
 @authentication_classes([AllowAny])
 @swagger_auto_schema(method='GET',
-                     responses={200: 'A JSON object with angular distances of the given sky coordinates from the given solar system bodies at the given timestamps and stations. \n'
-                                     'Outer list contains results per coordinate in given order, inner list per timestamp.'},
-                     operation_description="Get angular distances of the given sky coordinates from the given solar system bodies at all given timestamps and stations. \n\n"
-                                           "Example request: /api/util/sun_rise_and_set?coordinates=,CS005&timestamps=2020-05-01,2020-09-09T11-11-00",
-                     manual_parameters=[Parameter(name='ras', required=True, type='string', in_='query',
-                                                  description="comma-separated list of right ascensions (celestial longitudes)"),
-                                        Parameter(name='decs', required=True, type='string', in_='query',
-                                                  description="comma-separated list of declinations (celestial latitude)"),
-                                        Parameter(name='stations', required=False, type='string', in_='query',
-                                                  description="comma-separated list of station names"),
+                     responses={200: 'A JSON object with angular distances of the given sky coordinates from the given solar system bodies at the given timestamps (seen from LOFAR core)'},
+                     operation_description="Get angular distances of the given sky coordinates from the given solar system bodies at all given timestamps. \n\n"
+                                           "Example request: /api/util/angular_separation_from_bodies?angle1=1&angle2=1&timestamps=2020-01-01T15,2020-01-01T16",
+                     manual_parameters=[Parameter(name='angle1', required=True, type='string', in_='query',
+                                                  description="first angle of celectial coordinates as float, e.g. RA"),
+                                        Parameter(name='angle2', required=True, type='string', in_='query',
+                                                  description="second angle of celectial coordinates as float, e.g. RA"),
+                                        Parameter(name='direction_type', required=False, type='string', in_='query',
+                                                  description="direction_type of celectial coordinates as string, e.g. J2000"),
                                         Parameter(name='timestamps', required=False, type='string', in_='query',
                                                   description="comma-separated list of isoformat timestamps"),
                                         Parameter(name='bodies', required=False, type='string', in_='query',
@@ -194,40 +194,28 @@ def get_angular_separation_from_bodies(request):
     '''
     returns angular distances of the given sky coordinates from the given astronomical objects at the given timestamps and stations
     '''
-    stations = request.GET.get('stations', None)
     timestamps = request.GET.get('timestamps', None)
-    ras = request.GET.get('ras', None)
-    decs = request.GET.get('decs', None)
-    bodies = request.GET.get('bodies', None)
+    angle1 = request.GET.get('angle1')
+    angle2 = request.GET.get('angle2')
+    direction_type = request.GET.get("direction_type", "J2000")
+    bodies = tuple(request.GET.get('bodies', "sun,moon,jupiter").split(','))
+
+    if angle1 is None or angle2 is None:
+        raise ValueError("Please provide celestial coordinates in radians/J2000 via the 'angle1', 'angle2' and 'direction_type' properties.")
 
     if timestamps is None:
-        timestamps = [datetime.utcnow()]
+        timestamps = (datetime.utcnow(),)
     else:
         timestamps = timestamps.split(',')
-        timestamps = [dateutil.parser.parse(timestamp) for timestamp in timestamps]  #  isot to datetime
+        timestamps = tuple([dateutil.parser.parse(timestamp) for timestamp in timestamps])  #  isot to datetime
 
-    if bodies is None:
-        bodies = ['sun', 'moon', 'jupiter']
-    else:
-        bodies = bodies.split(',')
+    # calculate
+    sep_dict = coordinates_and_timestamps_to_separation_from_bodies(angle1=angle1, angle2=angle2, direction_type=direction_type, bodies=bodies, timestamps=timestamps)
+    new_sep_dict = {}
 
-    if stations is None:
-        stations = ["CS002"]
-    else:
-        stations = stations.split(',')
-
-    if ras is None or decs is None:
-        raise ValueError("Please provide celestial coordinates in radians/J2000 via the 'ras' and 'decs' the properties.")
-
-    coords = []
-    longitudes = ras.split(',')
-    latitudes = decs.split(',')
-    for ra, dec in zip(longitudes, latitudes):
-        coords.append(SkyCoord(ra=ra, dec=dec, unit=u.rad, equinox='J2000'))
-
-    sep_dict = coordinates_and_timestamps_to_separation_from_bodies(coords=coords, stations=stations, bodies=bodies, timestamps=timestamps)
-    for station, v in sep_dict.items():
-        for coord in v:
-            for object, angles in coord.items():
-                 coord[object] = [angle.rad for angle in angles]
-    return JsonResponse(sep_dict)
+    # serialize angles and datetimes for json response
+    for body, timestamps in sep_dict.items():
+        for timestamp, angle in timestamps.items():
+            new_sep_dict.setdefault(body, {})[timestamp.isoformat()] = angle.rad
+
+    return JsonResponse(new_sep_dict)
diff --git a/SAS/TMSS/test/t_conversions.py b/SAS/TMSS/test/t_conversions.py
index 8d987ce104c..141ae89c764 100755
--- a/SAS/TMSS/test/t_conversions.py
+++ b/SAS/TMSS/test/t_conversions.py
@@ -173,58 +173,62 @@ class UtilREST(unittest.TestCase):
         self.assertIn("celestial coordinates", r.content.decode('utf-8'))
 
     def test_util_angular_separation_from_bodies_returns_json_structure_with_defaults(self):
-        r = requests.get(BASE_URL + '/util/angular_separation_from_bodies?ras=1&decs=1', auth=AUTH)
+        r = requests.get(BASE_URL + '/util/angular_separation_from_bodies?angle1=1&angle2=1', auth=AUTH)
         self.assertEqual(r.status_code, 200)
         r_dict = json.loads(r.content.decode('utf-8'))
 
-        # assert defaults to core and today
-        self.assertIn('CS002', r_dict.keys())
+        # assert default bodies
         for key in ['sun', 'jupiter', 'moon']:
-            self.assertIn(key, r_dict['CS002'][0])
-        self.assertEqual(type(r_dict['CS002'][0]['jupiter'][0]), float)
+            self.assertIn(key, r_dict.keys())
 
-    def test_util_angular_separation_from_bodies_considers_stations(self):
-        stations = ['CS005', 'RS305', 'DE609']
-        r = requests.get(BASE_URL + '/util/angular_separation_from_bodies?ras=1&decs=1&stations=%s' % ','.join(stations), auth=AUTH)
+        # assert timestamp is now and has a value
+        returned_datetime = dateutil.parser.parse(list(r_dict['jupiter'].keys())[0])
+        current_datetime = datetime.datetime.utcnow()
+        delta = abs((returned_datetime - current_datetime).total_seconds())
+        self.assertTrue(delta < 60.0)
+        self.assertEqual(type(list(r_dict['jupiter'].values())[0]), float)
+
+    def test_util_angular_separation_from_bodies_considers_bodies(self):
+        bodies = ['sun', 'neptune', 'mercury']
+        r = requests.get(BASE_URL + '/util/angular_separation_from_bodies?angle1=1&angle2=1&bodies=%s' % ','.join(bodies), auth=AUTH)
         self.assertEqual(r.status_code, 200)
         r_dict = json.loads(r.content.decode('utf-8'))
 
         # assert station is included in response and angles differ
         angle_last = None
-        for station in stations:
-            self.assertIn(station, r_dict.keys())
-            angle = r_dict[station][0]['jupiter'][0]
+        for body in bodies:
+            self.assertIn(body, r_dict.keys())
+            angle = list(r_dict[body].values())[0]
             if angle_last:
                 self.assertNotEqual(angle, angle_last)
             angle_last = angle
 
     def test_util_angular_separation_from_bodies_considers_timestamps(self):
         timestamps = ['2020-01-01', '2020-02-22T16-00-00', '2020-3-11', '2020-01-01']
-        r = requests.get(BASE_URL + '/util/angular_separation_from_bodies?ras=1&decs=1&timestamps=%s' % ','.join(timestamps), auth=AUTH)
+        r = requests.get(BASE_URL + '/util/angular_separation_from_bodies?angle1=1&angle2=1&timestamps=%s' % ','.join(timestamps), auth=AUTH)
         self.assertEqual(r.status_code, 200)
         r_dict = json.loads(r.content.decode('utf-8'))
 
         # assert all requested timestamps yield a response and angles differ
-        self.assertEqual(len(timestamps), len(r_dict['CS002'][0]['jupiter']))
         angle_last = None
-        for i in range(len(timestamps)):
-            angle = r_dict['CS002'][0]['jupiter'][i]
+        for timestamp in timestamps:
+            expected_timestamp = dateutil.parser.parse(timestamp).isoformat()
+            self.assertIn(expected_timestamp, r_dict['jupiter'])
+            angle = r_dict['jupiter'][expected_timestamp]
             if angle_last:
                 self.assertNotEqual(angle, angle_last)
             angle_last = angle
 
     def test_util_angular_separation_from_bodies_considers_coordinates(self):
-        ras = ['1.0', '1.1', '1.2']
-        decs = ['1.0', '1.1', '1.2']
-        r = requests.get(BASE_URL + '/util/angular_separation_from_bodies?ras=%s&decs=%s' % (','.join(ras), ','.join(decs)), auth=AUTH)
-        self.assertEqual(r.status_code, 200)
-        r_dict = json.loads(r.content.decode('utf-8'))
+        test_coords = [(1, 1,"J2000"), (1.1, 1, "J2000"), (1.1, 1.1, "J2000")]
+        for coords in test_coords:
+            r = requests.get(BASE_URL + '/util/angular_separation_from_bodies?angle1=%s&angle2=%s&direction_type=%s' % coords, auth=AUTH)
+            self.assertEqual(r.status_code, 200)
+            r_dict = json.loads(r.content.decode('utf-8'))
 
-        # assert all requested timestamps yield a response and angles differ
-        self.assertEqual(len(ras), len(r_dict['CS002']))
-        angle_last = None
-        for i in range(len(ras)):
-            angle = r_dict['CS002'][i]['jupiter'][0]
+            # assert all requested timestamps yield a response and angles differ
+            angle_last = None
+            angle = list(r_dict['jupiter'].values())[0]
             if angle_last:
                 self.assertNotEqual(angle, angle_last)
             angle_last = angle
-- 
GitLab