diff --git a/SAS/TMSS/services/scheduling/lib/constraints/template_constraints_v1.py b/SAS/TMSS/services/scheduling/lib/constraints/template_constraints_v1.py index 27c764a783f8fdf149ef3d961a7ef7a532673191..dd4f5451a7e844d41a5070b3e083d2cbe4f3fca2 100644 --- a/SAS/TMSS/services/scheduling/lib/constraints/template_constraints_v1.py +++ b/SAS/TMSS/services/scheduling/lib/constraints/template_constraints_v1.py @@ -29,9 +29,11 @@ import logging logger = logging.getLogger(__name__) from datetime import datetime, timedelta from dateutil import parser +from astropy.coordinates import Angle +import astropy.units from lofar.sas.tmss.tmss.tmssapp import models -from lofar.sas.tmss.tmss.tmssapp.conversions import create_astroplan_observer_for_station, Time, timestamps_and_stations_to_sun_rise_and_set, coordinates_and_timestamps_to_separation_from_bodies +from lofar.sas.tmss.tmss.tmssapp.conversions import create_astroplan_observer_for_station, Time, timestamps_and_stations_to_sun_rise_and_set, coordinates_and_timestamps_to_separation_from_bodies, coordinates_timestamps_and_stations_to_target_rise_and_set from lofar.sas.tmss.tmss.exceptions import TMSSException from . import ScoredSchedulingUnit @@ -194,6 +196,9 @@ def can_run_anywhere_within_timewindow_with_sky_constraints(scheduling_unit: mod :return: True if all sky constraints are met over the entire time window, else False. """ constraints = scheduling_unit.draft.scheduling_constraints_doc + if not "sky" in constraints: + return True + for task in scheduling_unit.requirements_doc['tasks'].values(): if 'specifications_doc' in task: if 'tile_beam' in task['specifications_doc']: @@ -201,7 +206,8 @@ def can_run_anywhere_within_timewindow_with_sky_constraints(scheduling_unit: mod angle1 = beam['angle1'] angle2 = beam['angle2'] direction_type = beam['direction_type'] - if "sky" in constraints and 'min_distance' in constraints['sky']: + + if 'min_distance' in constraints['sky']: # currently we only check at bounds, we probably want to add some more samples in between later on distances = coordinates_and_timestamps_to_separation_from_bodies(angle1=angle1, angle2=angle2, direction_type=direction_type, timestamps=(lower_bound, upper_bound), bodies=tuple(constraints['sky']['min_distance'].keys())) for body, min_distance in constraints['sky']['min_distance'].items(): @@ -211,6 +217,25 @@ def can_run_anywhere_within_timewindow_with_sky_constraints(scheduling_unit: mod logger.info('Distance=%s from body=%s does not meet min_distance=%s constraint at timestamp=%s' % (angle.rad, body, min_distance, timestamp)) return False + if 'min_target_elevation' in constraints['sky'] and task['specifications_template'] == 'target observation' or \ + 'min_calibrator_elevation' in constraints['sky'] and task['specifications_template'] == 'calibrator observation': + if task['specifications_template'] == 'calibrator observation': + min_elevation = Angle(constraints['sky']['min_calibrator_elevation'], unit=astropy.units.rad) + else: + min_elevation = Angle(constraints['sky']['min_target_elevation'], unit=astropy.units.rad) + timestamps = (lower_bound, upper_bound) + stations = task['specifications_doc']['stations'] + # currently we only check at bounds, we probably want to add some more samples in between later on + target_rise_and_set_times = coordinates_timestamps_and_stations_to_target_rise_and_set(angle1=angle1, angle2=angle2, direction_type=direction_type, timestamps=timestamps, stations=tuple(stations), angle_to_horizon=min_elevation) + for station, times in target_rise_and_set_times.items(): + for i in range(len(timestamps)): + if not (timestamps[i] > times[0]['rise'] and timestamps[i] < times[0]['set']): + if task['specifications_template'] == 'calibrator observation': + logger.info('min_calibrator_elevation=%s constraint is not met at timestamp=%s' % (min_elevation.rad, timestamps[i])) + else: + logger.info('min_target_elevation=%s constraint is not met at timestamp=%s' % (min_elevation.rad, timestamps[i])) + return False + return True diff --git a/SAS/TMSS/services/scheduling/test/t_dynamic_scheduling.py b/SAS/TMSS/services/scheduling/test/t_dynamic_scheduling.py index 10cd3206512e03b8c52a20796ce75b46dfe10384..343cc8925a42702dc814b06c808c68d9498f95ee 100755 --- a/SAS/TMSS/services/scheduling/test/t_dynamic_scheduling.py +++ b/SAS/TMSS/services/scheduling/test/t_dynamic_scheduling.py @@ -420,10 +420,13 @@ class TestDailyConstraints(TestCase): lower_bound = datetime(2020, 1, 1, 8, 0, 0) upper_bound = datetime(2020, 1, 1, 12, 0, 0) self.assertFalse(tc1.can_run_anywhere_within_timewindow_with_daily_constraints(self.scheduling_unit_blueprint, lower_bound, upper_bound)) - + def test_can_run_within_timewindow_with_daytime_constraint_returns_correct_value(self): # todo: for time ranges across dates, consider removing the mock for this because the moving window cannot be easily mocked - self.scheduling_unit_blueprint.draft.scheduling_constraints_doc['sky']['min_distance'] = {} # remove sky constraint + # remove other constraints: + self.scheduling_unit_blueprint.draft.scheduling_constraints_doc['sky'] = {} + + # set constraint to test self.scheduling_unit_blueprint.draft.scheduling_constraints_doc['daily']['require_day'] = True self.scheduling_unit_blueprint.save() @@ -551,10 +554,13 @@ class TestDailyConstraints(TestCase): lower_bound = datetime(2020, 1, 1, 3, 0, 0) upper_bound = datetime(2020, 1, 1, 23, 0, 0) self.assertFalse(tc1.can_run_anywhere_within_timewindow_with_daily_constraints(self.scheduling_unit_blueprint, lower_bound, upper_bound)) - + def test_can_run_within_timewindow_with_nighttime_constraint_returns_correct_value(self): # todo: for time ranges across dates, consider removing the mock for this because the moving window cannot be easily mocked - self.scheduling_unit_blueprint.draft.scheduling_constraints_doc['sky']['min_distance'] = {} # remove sky constraint + # remove other constraints: + self.scheduling_unit_blueprint.draft.scheduling_constraints_doc['sky'] = {} + + # set constraint to test self.scheduling_unit_blueprint.draft.scheduling_constraints_doc['daily']['require_night'] = True self.scheduling_unit_blueprint.save() @@ -569,7 +575,7 @@ class TestDailyConstraints(TestCase): lower_bound = datetime(2020, 1, 1, 15, 0, 0) upper_bound = datetime(2020, 1, 1, 23, 0, 0) self.assertTrue(can_run_within_timewindow(self.scheduling_unit_blueprint, lower_bound, upper_bound)) - + # avoid_twilight @@ -668,7 +674,6 @@ class TestDailyConstraints(TestCase): self.assertFalse(tc1.can_run_anywhere_within_timewindow_with_daily_constraints(self.scheduling_unit_blueprint, lower_bound, upper_bound)) def test_can_run_anywhere_within_timewindow_with_daily_constraints_with_twilight_constraint_returns_false_when_partially_in_twilight(self): - self.scheduling_unit_blueprint.draft.scheduling_constraints_doc['sky']['min_distance'] = {} # remove sky constraint self.scheduling_unit_blueprint.draft.scheduling_constraints_doc['daily']['avoid_twilight'] = True self.scheduling_unit_blueprint.save() @@ -684,7 +689,10 @@ class TestDailyConstraints(TestCase): def test_can_run_within_timewindow_with_twilight_constraint_returns_correct_value(self): # todo: for time ranges across dates, consider removing the mock for this because the moving window cannot be easily mocked - self.scheduling_unit_blueprint.draft.scheduling_constraints_doc['sky']['min_distance'] = {} # remove sky constraint + # remove other constraints: + self.scheduling_unit_blueprint.draft.scheduling_constraints_doc['sky'] = {} + + # set constraint to test self.scheduling_unit_blueprint.draft.scheduling_constraints_doc['daily']['avoid_twilight'] = True self.scheduling_unit_blueprint.save() @@ -725,22 +733,45 @@ class TestSkyConstraints(unittest.TestCase): self.distance_mock = self.distance_patcher.start() self.distance_mock.return_value = self.distance_data self.addCleanup(self.distance_patcher.stop) + + self.target_rise_and_set_data = {"CS002": [{"rise": datetime(2020, 1, 1, 8, 0, 0), "set": datetime(2020, 1, 1, 12, 30, 0)}, + {"rise": datetime(2020, 1, 1, 8, 0, 0), "set": datetime(2020, 1, 1, 12, 30, 0)}]} + self.target_rise_and_set_patcher = mock.patch('lofar.sas.tmss.services.scheduling.constraints.template_constraints_v1.coordinates_timestamps_and_stations_to_target_rise_and_set') + self.target_rise_and_set_mock = self.target_rise_and_set_patcher.start() + self.target_rise_and_set_mock.return_value = self.target_rise_and_set_data + self.addCleanup(self.target_rise_and_set_patcher.stop) # min_distance def test_can_run_anywhere_within_timewindow_with_sky_constraints_with_min_distance_constraint_returns_true_when_met(self): - self.scheduling_unit_blueprint.draft.scheduling_constraints_doc['sky']['min_distance'] = {'sun': 0.1, 'moon': 0.1, 'jupiter': 0.1} + self.scheduling_unit_blueprint.draft.scheduling_constraints_doc['sky'] = {'min_distance': {'sun': 0.1, 'moon': 0.1, 'jupiter': 0.1}} self.scheduling_unit_blueprint.save() timestamp = datetime(2020, 1, 1, 10, 0, 0) returned_value = tc1.can_run_anywhere_within_timewindow_with_sky_constraints(self.scheduling_unit_blueprint, timestamp, timestamp + timedelta(seconds=self.obs_duration)) self.assertTrue(returned_value) def test_can_run_anywhere_within_timewindow_with_sky_constraints_with_min_distance_constraint_returns_false_when_not_met(self): - self.scheduling_unit_blueprint.draft.scheduling_constraints_doc['sky']['min_distance'] = {'sun': 0.2, 'moon': 0.2, 'jupiter': 0.2} + self.scheduling_unit_blueprint.draft.scheduling_constraints_doc['sky'] = {'min_distance': {'sun': 0.2, 'moon': 0.2, 'jupiter': 0.2}} self.scheduling_unit_blueprint.save() timestamp = datetime(2020, 1, 1, 10, 0, 0) returned_value = tc1.can_run_anywhere_within_timewindow_with_sky_constraints(self.scheduling_unit_blueprint, timestamp, timestamp + timedelta(seconds=self.obs_duration)) self.assertFalse(returned_value) + + # min_target_elevation + + def test_can_run_anywhere_within_timewindow_with_sky_constraints_with_min_target_elevation_constraint_returns_true_when_met(self): + self.scheduling_unit_blueprint.draft.scheduling_constraints_doc['sky'] = {'min_target_elevation': 0.1} + self.scheduling_unit_blueprint.save() + timestamp = datetime(2020, 1, 1, 10, 0, 0) + returned_value = tc1.can_run_anywhere_within_timewindow_with_sky_constraints(self.scheduling_unit_blueprint, timestamp, timestamp + timedelta(seconds=self.obs_duration)) + self.assertTrue(returned_value) + + def test_can_run_anywhere_within_timewindow_with_sky_constraints_with_min_target_elevation_constraint_returns_false_when_not_met(self): + self.scheduling_unit_blueprint.draft.scheduling_constraints_doc['sky'] = {'min_target_elevation': 0.2} + self.scheduling_unit_blueprint.save() + timestamp = datetime(2020, 1, 1, 11, 0, 0) + returned_value = tc1.can_run_anywhere_within_timewindow_with_sky_constraints(self.scheduling_unit_blueprint, timestamp, timestamp + timedelta(seconds=self.obs_duration)) + self.assertFalse(returned_value) logging.basicConfig(format='%(asctime)s %(levelname)s %(message)s', level=logging.INFO) diff --git a/SAS/TMSS/src/tmss/tmssapp/conversions.py b/SAS/TMSS/src/tmss/tmssapp/conversions.py index 335b8937493b5c3e26fa9f0a80b798bee31107c0..40765b6998575b0cdccb3b1d11c113c527f64cb3 100644 --- a/SAS/TMSS/src/tmss/tmssapp/conversions.py +++ b/SAS/TMSS/src/tmss/tmssapp/conversions.py @@ -25,7 +25,8 @@ def create_astroplan_observer_for_station(station: str) -> Observer: # default angle to the horizon at which the sunset/sunrise starts and ends, as per LOFAR definition. SUN_SET_RISE_ANGLE_TO_HORIZON = Angle(10, unit=astropy.units.deg) -SUN_SET_RISE_PRECISION = 30 # n_grid_points; higher is more precise but very costly; astropy defaults to 150, errors now can be in the minutes, increase if this is not good enough +# default n_grid_points; higher is more precise but very costly; astropy defaults to 150, errors now can be in the minutes, increase if this is not good enough +SUN_SET_RISE_PRECISION = 30 @lru_cache(maxsize=256, typed=False) # does not like lists, so use tuples 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: @@ -35,6 +36,7 @@ def timestamps_and_stations_to_sun_rise_and_set(timestamps: tuple, stations: tup The night is usually the one _starting_ on the date of the time stamp, unless the given timestamp falls before sunrise, in which case it is the night _ending_ on the timestamp date. :param timestamps: tuple of datetimes, e.g. (datetime(2020, 1, 1), datetime(2020, 1, 2)) :param stations: tuple of station names, e.g. ("CS002",) + :param angle_to_horizon: the angle between horizon and given coordinates for which rise and set times are returned :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": @@ -55,7 +57,7 @@ def timestamps_and_stations_to_sun_rise_and_set(timestamps: tuple, stations: tup # todo: this can probably be made faster by moving the following logic to an own function with single station/timestamp as input and putting the lru_cache on there. # This also means that we have to strip the time from the datetime. Can this be safely done? observer = create_astroplan_observer_for_station(station) - sunrise_start = observer.sun_rise_time(time=Time(datetime.combine(timestamp.date(), dtime(12,0,0))), which='previous', n_grid_points=SUN_SET_RISE_PRECISION) + sunrise_start = observer.sun_rise_time(time=Time(datetime.combine(timestamp.date(), dtime(12,0,0))), horizon=-angle_to_horizon, which='previous', n_grid_points=SUN_SET_RISE_PRECISION) sunrise_end = observer.sun_rise_time(time=Time(sunrise_start), horizon=angle_to_horizon, which='next', n_grid_points=SUN_SET_RISE_PRECISION) sunset_start = observer.sun_set_time(time=sunrise_end, horizon=angle_to_horizon, which='next', n_grid_points=SUN_SET_RISE_PRECISION) sunset_end = observer.sun_set_time(time=sunset_start, horizon=-angle_to_horizon, which='next', n_grid_points=SUN_SET_RISE_PRECISION) @@ -91,7 +93,7 @@ def coordinates_and_timestamps_to_separation_from_bodies(angle1: float, angle2: } """ if direction_type == "J2000": - coord = astropy.coordinates.SkyCoord(ra=angle1, dec=angle2, unit=astropy.units.deg) + coord = astropy.coordinates.SkyCoord(ra=angle1, dec=angle2, unit=astropy.units.rad) else: raise ValueError("Do not know how to convert direction_type=%s to SkyCoord" % direction_type) return_dict = {} @@ -105,6 +107,48 @@ def coordinates_and_timestamps_to_separation_from_bodies(angle1: float, angle2: return return_dict +# default angle above horizon, above which the target it reporte as 'up' +TARGET_SET_RISE_ANGLE_TO_HORIZON = Angle(0, unit=astropy.units.deg) # if default should be non-zero, should we include it explicitly in response? +# default n_grid_points; higher is more precise but very costly; astropy defaults to 150, note that errors can be in the minutes with a lower values +TARGET_SET_RISE_PRECISION = 150 + +@lru_cache(maxsize=256, typed=False) # does not like lists, so use tuples to allow caching +def coordinates_timestamps_and_stations_to_target_rise_and_set(angle1: float, angle2: float, direction_type: str, timestamps: tuple, stations: tuple, angle_to_horizon: Angle=TARGET_SET_RISE_ANGLE_TO_HORIZON) -> dict: + """ + Compute rise and set times of the given coordinates above the provided horizon, for each given station and timestamp. + The set time is always the one following the provided timestamp. + This implies that if the target is up at a given timestamp, the surrounding rise and set times are returned. + Otherwise both rise and set times follow the timestamp. + :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), datetime(2020, 1, 2)) + :param stations: tuple of station names, e.g. ("CS002",) + :param angle_to_horizon: the angle between horizon and given coordinates for which rise and set times are returned + :return A dict that maps station names to a list of dicts with rise and set times for each requested date. + E.g. + {"CS002": [{"rise": datetime(2020, 1, 1, 4, 0, 0), "set": datetime(2020, 1, 1, 11, 0, 0)}, + {"rise": datetime(2020, 1, 2, 4, 0, 0), "set": datetime(2020, 1, 2, 11, 0, 0)}] + } + """ + if direction_type == "J2000": + coord = astropy.coordinates.SkyCoord(ra=angle1, dec=angle2, unit=astropy.units.rad) + else: + raise ValueError("Do not know how to convert direction_type=%s to SkyCoord" % direction_type) + return_dict = {} + for station in stations: + for timestamp in timestamps: + # todo: this can probably be made faster by moving the following logic to an own function with single station/timestamp as input and putting the lru_cache on there. + observer = create_astroplan_observer_for_station(station) + target_set = observer.target_set_time(target=coord, time=Time(timestamp), horizon=angle_to_horizon, which='next', n_grid_points=TARGET_SET_RISE_PRECISION) + target_rise = observer.target_rise_time(target=coord, time=Time(target_set), horizon=angle_to_horizon, which='previous', n_grid_points=TARGET_SET_RISE_PRECISION) + + return_dict.setdefault(station, []).append({"rise": target_rise.to_datetime(), "set": target_set.to_datetime()}) + + return return_dict + + + def local_sidereal_time_for_utc_and_station(timestamp: datetime = None, station: str = 'CS002', field: str = 'LBA', diff --git a/SAS/TMSS/src/tmss/tmssapp/views.py b/SAS/TMSS/src/tmss/tmssapp/views.py index b099d553958475e0d7410dc7bef529503b7f9d4d..8dabf0b06f1967e925ea8fac41e80afb84e31387 100644 --- a/SAS/TMSS/src/tmss/tmssapp/views.py +++ b/SAS/TMSS/src/tmss/tmssapp/views.py @@ -15,7 +15,9 @@ from django.apps import apps from rest_framework.decorators import api_view from datetime import datetime import dateutil.parser -from lofar.sas.tmss.tmss.tmssapp.conversions import local_sidereal_time_for_utc_and_station, local_sidereal_time_for_utc_and_longitude, timestamps_and_stations_to_sun_rise_and_set, coordinates_and_timestamps_to_separation_from_bodies +from astropy.coordinates import Angle +import astropy.units +from lofar.sas.tmss.tmss.tmssapp.conversions import local_sidereal_time_for_utc_and_station, local_sidereal_time_for_utc_and_longitude, timestamps_and_stations_to_sun_rise_and_set, coordinates_and_timestamps_to_separation_from_bodies, coordinates_timestamps_and_stations_to_target_rise_and_set # Note: Decorate with @api_view to get this picked up by Swagger @@ -216,3 +218,54 @@ def get_angular_separation_from_bodies(request): new_sep_dict.setdefault(body, {})[timestamp.isoformat()] = angle.rad return JsonResponse(new_sep_dict) + + + +@permission_classes([AllowAny]) +@authentication_classes([AllowAny]) +@swagger_auto_schema(method='GET', + responses={200: 'A JSON object with rise and set times of the given coordinates above the provided horizon, for each given station and timestamp.'}, + operation_description="Get rise and set times of the given coordinates above the provided horizon, for each given station and timestamp. \n\n" + "Example request: /api/util/target_rise_and_set?angle1=0.5&angle2=0.5×tamps=2020-01-01T15&horizon=0.3", + 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='stations', required=False, type='string', in_='query', + description="comma-separated list of station names"), + Parameter(name='horizon', required=False, type='string', in_='query', + description="Elevation above horizon for which to return rise/set times as float")]) +@api_view(['GET']) +def get_target_rise_and_set(request): + ''' + returns rise and set times of the given coordinates above the provided horizon, for each given station and timestamp. + ''' + timestamps = request.GET.get('timestamps', None) + angle1 = request.GET.get('angle1') + angle2 = request.GET.get('angle2') + direction_type = request.GET.get("direction_type", "J2000") + stations = tuple(request.GET.get('stations', "CS002").split(',')) + horizon = request.GET.get('horizon', None) + + if angle1 is None or angle2 is None: + raise ValueError("Please provide celestial coordinates via 'angle1', 'angle2' (and optionally 'direction_type') properties.") + + if timestamps is None: + timestamps = (datetime.utcnow(),) + else: + timestamps = timestamps.split(',') + timestamps = tuple([dateutil.parser.parse(timestamp, ignoretz=True) for timestamp in timestamps]) # isot to datetime + + if horizon is None: + horizon = Angle(0, unit=astropy.units.rad) + else: + horizon = Angle(horizon, unit=astropy.units.rad) + + # calculate + rise_set_dict = coordinates_timestamps_and_stations_to_target_rise_and_set(angle1=angle1, angle2=angle2, direction_type=direction_type, angle_to_horizon=horizon, timestamps=timestamps, stations=stations) + return JsonResponse(rise_set_dict) + diff --git a/SAS/TMSS/src/tmss/urls.py b/SAS/TMSS/src/tmss/urls.py index 42c6ac8971fdf9632d1c2de771c1387f8c6845c1..dda3767daec991e0fe14e9f8330270c2249b8ef3 100644 --- a/SAS/TMSS/src/tmss/urls.py +++ b/SAS/TMSS/src/tmss/urls.py @@ -71,6 +71,7 @@ urlpatterns = [ re_path('util/utc/?', views.utc, name="system-utc"), re_path('util/lst/?', views.lst, name="conversion-lst"), re_path('util/angular_separation_from_bodies/?', views.get_angular_separation_from_bodies, name='get_angular_separation_from_bodies'), + re_path('util/target_rise_and_set/?', views.get_target_rise_and_set, name='get_target_rise_and_set'), ] if os.environ.get('SHOW_DJANGO_DEBUG_TOOLBAR', False): diff --git a/SAS/TMSS/test/t_conversions.py b/SAS/TMSS/test/t_conversions.py index 18865051aecdd7bd80946e68ef80487e58f8b815..c3e8e4fbcdc1fa33ef08cee7e664d415c71db441 100755 --- a/SAS/TMSS/test/t_conversions.py +++ b/SAS/TMSS/test/t_conversions.py @@ -76,6 +76,8 @@ class SiderealTime(unittest.TestCase): class UtilREST(unittest.TestCase): + # utc + def test_util_utc_returns_timestamp(self): # assert local clock differs not too much from returned TMSS system clock @@ -86,6 +88,8 @@ class UtilREST(unittest.TestCase): delta = abs((returned_datetime - current_datetime).total_seconds()) self.assertTrue(delta < 60.0) + # lst + def test_util_lst_returns_longitude(self): # assert returned value is a parseable hms value @@ -128,6 +132,8 @@ class UtilREST(unittest.TestCase): lon_str2 = r2.content.decode('utf8') self.assertNotEqual(lon_str1, lon_str2) + # sun_rise_and_set + def test_util_sun_rise_and_set_returns_json_structure_with_defaults(self): r = requests.get(BASE_URL + '/util/sun_rise_and_set', auth=AUTH) self.assertEqual(r.status_code, 200) @@ -196,6 +202,7 @@ class UtilREST(unittest.TestCase): response_date = dateutil.parser.parse(r_dict['CS002']['night'][1]['start']).date() self.assertEqual(expected_date, response_date) + # angular_separation_from_bodies def test_util_angular_separation_from_bodies_yields_error_when_no_pointing_is_given(self): r = requests.get(BASE_URL + '/util/angular_separation_from_bodies', auth=AUTH) @@ -265,6 +272,90 @@ class UtilREST(unittest.TestCase): self.assertNotEqual(angle, angle_last) angle_last = angle + # target_rise_and_set + + def test_util_target_rise_and_set_returns_json_structure_with_defaults(self): + r = requests.get(BASE_URL + '/util/target_rise_and_set?angle1=0.5&angle2=0.5', auth=AUTH) + self.assertEqual(r.status_code, 200) + r_dict = json.loads(r.content.decode('utf-8')) + + # defaults are CS002 and today + self.assertIn('CS002', r_dict.keys()) + rise = dateutil.parser.parse(r_dict['CS002'][0]['rise']) + self.assertEqual(datetime.date.today(), rise.date()) + + def test_util_target_rise_and_set_considers_stations(self): + stations = ['CS005', 'RS305', 'DE609'] + r = requests.get(BASE_URL + '/util/target_rise_and_set?angle1=0.5&angle2=0.5&stations=%s' % ','.join(stations), auth=AUTH) + self.assertEqual(r.status_code, 200) + r_dict = json.loads(r.content.decode('utf-8')) + + # assert station is included in response and timestamps differ + target_rise_last = None + for station in stations: + self.assertIn(station, r_dict.keys()) + target_rise = dateutil.parser.parse(r_dict[station][0]['rise']) + if target_rise_last: + self.assertNotEqual(target_rise, target_rise_last) + target_rise_last = target_rise + + def test_util_target_rise_and_set_considers_timestamps(self): + timestamps = ['2020-01-01', '2020-02-22T16-00-00', '2020-3-11', '2020-01-01'] + r = requests.get(BASE_URL + '/util/target_rise_and_set?angle1=0.5&angle2=0.5×tamps=%s' % ','.join(timestamps), auth=AUTH) + self.assertEqual(r.status_code, 200) + r_dict = json.loads(r.content.decode('utf-8')) + + # assert all requested timestamps are included in response and either rise or set are in that day + for i in range(len(timestamps)): + expected_date = dateutil.parser.parse(timestamps[i]).date() + response_rise_date = dateutil.parser.parse(r_dict['CS002'][i]['rise']).date() + response_set_date = dateutil.parser.parse(r_dict['CS002'][i]['set']).date() + self.assertTrue(expected_date == response_rise_date or expected_date == response_set_date) + + def test_util_target_rise_and_set_returns_correct_date_of_target_rise_and_set(self): + timestamps = ['2020-01-01T02-00-00'] + r = requests.get(BASE_URL + '/util/target_rise_and_set?angle1=0.5&angle2=0.5×tamps=%s' % ','.join(timestamps), auth=AUTH) + self.assertEqual(r.status_code, 200) + r_dict = json.loads(r.content.decode('utf-8')) + + # assert day of timestamp matches day of returned rise + expected_date = dateutil.parser.parse(timestamps[0]).date() + target_rise = dateutil.parser.parse(r_dict['CS002'][0]['rise']) + target_set = dateutil.parser.parse(r_dict['CS002'][0]['set']) + self.assertTrue(expected_date == target_rise.date() or expected_date == target_set.date()) + + # assert set time falls in the 24h after rise time + self.assertTrue(target_set - target_rise > datetime.timedelta(0) and target_set - target_rise < datetime.timedelta(days=1)) + + def test_util_target_rise_and_set_considers_coordinates(self): + test_coords = [(0.5, 0.5, "J2000"), (0.6, 0.5, "J2000"), (0.6, 0.6, "J2000")] + for coords in test_coords: + r = requests.get(BASE_URL + '/util/target_rise_and_set?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 coordinates yield a response and times differ + rise_last = None + rise = r_dict['CS002'][0]['rise'] + if rise_last: + self.assertNotEqual(rise, rise_last) + rise_last = rise + + def test_util_target_rise_and_set_considers_horizon(self): + test_horizons = [0.1, 0.2, 0.3] + for horizon in test_horizons: + r = requests.get(BASE_URL + '/util/target_rise_and_set?angle1=0.5&angle2=0.5&horizon=%s' % horizon, auth=AUTH) + self.assertEqual(r.status_code, 200) + r_dict = json.loads(r.content.decode('utf-8')) + + # assert all requested horizons yield a response and times differ + rise_last = None + rise = r_dict['CS002'][0]['rise'] + if rise_last: + self.assertNotEqual(rise, rise_last) + rise_last = rise + + if __name__ == "__main__": os.environ['TZ'] = 'UTC' unittest.main()