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 b19b735b07e76d68ea99ced2bcc57f565bf16da5..700556966058fb2fb0046e8409b65f5f85b1d131 100644 --- a/SAS/TMSS/services/scheduling/lib/constraints/template_constraints_v1.py +++ b/SAS/TMSS/services/scheduling/lib/constraints/template_constraints_v1.py @@ -38,15 +38,19 @@ from . import ScoredSchedulingUnit def can_run_within_timewindow(scheduling_unit: models.SchedulingUnitBlueprint, lower_bound: datetime, upper_bound: datetime) -> bool: '''determine if the given scheduling_unit can run withing the given timewindow evaluating all constraints from the "constraints" version 1 template''' if has_manual_scheduler_constraint(scheduling_unit): + logger.info("### SchedulingUnitBlueprint id=%s has manual scheduler constraint and cannot be dynamically scheduled." % (scheduling_unit.id)) return False if not can_run_within_timewindow_with_time_constraints(scheduling_unit, lower_bound, upper_bound): + logger.info("### SchedulingUnitBlueprint id=%s does not meet time constraints between %s and %s." % (scheduling_unit.id, lower_bound, upper_bound)) return False if not can_run_within_timewindow_with_sky_constraints(scheduling_unit, lower_bound, upper_bound): + logger.info("### SchedulingUnitBlueprint id=%s does not meet sky constraints between %s and %s." % (scheduling_unit.id, lower_bound, upper_bound)) return False if not can_run_within_timewindow_with_daily_constraints(scheduling_unit, lower_bound, upper_bound): + logger.info("### SchedulingUnitBlueprint id=%s does not meet daily constraints between %s and %s." % (scheduling_unit.id, lower_bound, upper_bound)) return False return True @@ -74,38 +78,55 @@ def has_manual_scheduler_constraint(scheduling_unit: models.SchedulingUnitBluepr def can_run_within_timewindow_with_daily_constraints(scheduling_unit: models.SchedulingUnitBlueprint, lower_bound: datetime, upper_bound: datetime) -> bool: '''evaluate the daily contraint''' constraints = scheduling_unit.draft.scheduling_constraints_doc - if not (constraints['daily']['require_day'] and constraints['daily']['require_night'] and constraints['daily']['avoid_twilight']): - # no day/night restrictions, can run any time - return True - if constraints['daily']['require_day'] or constraints['daily']['require_night'] or constraints['daily']['avoid_twilight']: - # compute some timestamps over observation time - timesteps = 2 + 3 * (lower_bound - upper_bound).days - delta = (upper_bound - lower_bound) / timesteps - timestamps = [lower_bound + n * delta for n in range(timesteps + 1)] + if (upper_bound - lower_bound).days >= 1: + logger.info("### SchedulingUnitBlueprint id=%s has daily constraints, but bounds span %s" % (scheduling_unit.id, (upper_bound - lower_bound))) + return False + + if upper_bound < lower_bound: + raise ValueError("Provided upper_bound=%s is earlier than provided lower_bound=%s" % (upper_bound, lower_bound)) - # get day/night for all timestamps stations = scheduling_unit.requirements_doc['tasks']['Observation']['specifications_doc']['stations'] # check contraint and return false on first failure for station in stations: - sun_events = timestamps_and_stations_to_sun_rise_and_set(timestamps=timestamps, stations=(station,))[station] + # get day/night times for bounds + # we could sample in between bounds, but will instead do some checks + if constraints['daily']['require_day'] and lower_bound.date() != upper_bound.date(): + logger.info("### SchedulingUnitBlueprint id=%s cannot meet require_day constraint when starting and ending on different days." % scheduling_unit.id) + return False + timestamps = [lower_bound, upper_bound] + sun_events = timestamps_and_stations_to_sun_rise_and_set(timestamps=tuple(timestamps), stations=(station,))[station] if constraints['daily']['require_day']: - for i in range(timesteps + 1): + for i in range(len(timestamps)): if timestamps[i] < sun_events['day'][i]['start'] or timestamps[i] > sun_events['day'][i]['end']: + logger.info("### %s not between %s and %s" % (timestamps[i], sun_events['day'][i]['start'], sun_events['day'][i]['end'])) + logger.info("### SchedulingUnitBlueprint id=%s does not meet require_day constraint at timestamp=%s" % (scheduling_unit.id, timestamps[i])) return False + else: + logger.info("### %s between %s and %s" % (timestamps[i], sun_events['day'][i]['start'], sun_events['day'][i]['end'] )) if constraints['daily']['require_night']: - for i in range(timesteps + 1): + if sun_events['night'][0]['start'].date() != sun_events['night'][1]['start'].date(): + logger.info("### SchedulingUnitBlueprint id=%s cannot meet require_night constraint when starting and ending in different nights." % scheduling_unit.id) + return False + for i in range(len(timestamps)): if timestamps[i] < sun_events['night'][i]['start'] or timestamps[i] > sun_events['night'][i]['end']: + logger.info("### SchedulingUnitBlueprint id=%s does not meet require_night constraint at timestamp=%s" % (scheduling_unit.id, timestamps[i])) return False if constraints['daily']['avoid_twilight']: - for i in range(timesteps + 1): - if timestamps[i] < sun_events['day'][i]['start'] or (timestamps[i] > sun_events['day'][i]['end'] and timestamps[i] < sun_events['night'][i]['start']): # todo: I guess we have to consider previous night here - return False + # Note: the same index for sun_events everywhere is not a typo, but to make sure it's the _same_ night or day for both bounds or obs will span over twilight + if not (timestamps[0] > sun_events['day'][0]['start'] and timestamps[0] < sun_events['day'][0]['end'] and + timestamps[1] > sun_events['day'][0]['start'] and timestamps[1] < sun_events['day'][0]['end']) or \ + (timestamps[0] > sun_events['night'][0]['start'] and timestamps[0] < sun_events['night'][0]['end'] and + timestamps[1] > sun_events['night'][0]['start'] and timestamps[1] < sun_events['night'][0]['end']): + logger.info("### SchedulingUnitBlueprint id=%s does not meet avoid_twilight constraint." % scheduling_unit.id) + return False + + logger.info('### SchedulingUnitBlueprint id=%s meets all daily constraints. Returning True.') return True @@ -134,6 +155,7 @@ def can_run_within_timewindow_with_time_constraints(scheduling_unit: models.Sche def can_run_within_timewindow_with_sky_constraints(scheduling_unit: models.SchedulingUnitBlueprint, lower_bound: datetime, upper_bound: datetime) -> bool: '''evaluate the time contraint(s)''' + logger.info('### can_run_within_timewindow_with_sky_constraints called with lower_bound=%s upper_bound=%s '% (lower_bound, upper_bound)) constraints = scheduling_unit.draft.scheduling_constraints_doc # TODO: TMSS-245 TMSS-250 (and more?), evaluate the constraints in constraints['sky'] # maybe even split this method into sub methods for the very distinct sky constraints: min_calibrator_elevation, min_target_elevation, transit_offset & min_distance @@ -143,12 +165,13 @@ def can_run_within_timewindow_with_sky_constraints(scheduling_unit: models.Sched angle2 = beam['angle2'] direction_type = beam['direction_type'] if "sky" in constraints and '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, timestamps in distances.items(): + for body, min_distance in constraints['sky']['min_distance'].items(): + timestamps = distances[body] for timestamp, angle in timestamps.items(): - min_distance = constraints['sky']['min_distance'][body] if angle.rad < min_distance: - logger.info('Distance=%s from body=%s does not meet min_distance=%s constraint at timestamp=%s' % (angle.rad, body, min_distance, timestamp)) + 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 return True @@ -212,7 +235,6 @@ def get_earliest_possible_start_time(scheduling_unit: models.SchedulingUnitBluep continue start_time_per_station[station] = next_day['start'] continue - logger.info('#### %s' % start_time_per_station) return max(start_time_per_station.values()) except Exception as e: logger.exception(str(e)) diff --git a/SAS/TMSS/services/scheduling/test/t_dynamic_scheduling.py b/SAS/TMSS/services/scheduling/test/t_dynamic_scheduling.py index 2dedb92c2270ad04c9905faef92e79950f0dad37..799f40bfe73c33fdfbf52fede5effd4d2bed92ab 100755 --- a/SAS/TMSS/services/scheduling/test/t_dynamic_scheduling.py +++ b/SAS/TMSS/services/scheduling/test/t_dynamic_scheduling.py @@ -271,7 +271,7 @@ class TestDynamicScheduling(unittest.TestCase): self.assertGreaterEqual(scheduling_unit_blueprint_high.start_time - scheduling_unit_blueprint_manual.stop_time, DEFAULT_INTER_OBSERVATION_GAP) -class TestSchedulingConstraints(unittest.TestCase): +class TestDailyConstraints(unittest.TestCase): ''' Tests for the constraint checkers used in dynamic scheduling ''' @@ -280,14 +280,15 @@ class TestSchedulingConstraints(unittest.TestCase): # scheduling unit self.obs_duration = 120 * 60 scheduling_set = models.SchedulingSet.objects.create(**SchedulingSet_test_data()) - scheduling_unit_draft = TestDynamicScheduling.create_simple_observation_scheduling_unit("scheduling unit for contraints tests", + scheduling_unit_draft = TestDynamicScheduling.create_simple_observation_scheduling_unit("scheduling unit for ...%s" % self._testMethodName[30:], scheduling_set=scheduling_set, obs_duration=self.obs_duration) self.scheduling_unit_blueprint = create_task_blueprints_and_subtasks_from_scheduling_unit_draft(scheduling_unit_draft) # mock out conversions for speedup and assertable timestamps + # earliest_start_time requests timestamp and timestamp+1day self.sunrise_data = { - 'CS001': {"sunrise": [{"start": datetime(2020, 1, 1, 7, 30, 0), "end": datetime(2020, 1, 1, 9, 30, 0)},{"start": datetime(2020, 1, 2, 7, 30, 0), "end": datetime(2020, 1, 2, 9, 30, 0)}], + 'CS001': {"sunrise": [{"start": datetime(2020, 1, 1, 7, 30, 0), "end": datetime(2020, 1, 1, 9, 30, 0)}, {"start": datetime(2020, 1, 2, 7, 30, 0), "end": datetime(2020, 1, 2, 9, 30, 0)}], "day": [{"start": datetime(2020, 1, 1, 9, 30, 0), "end": datetime(2020, 1, 1, 15, 30, 0)}, {"start": datetime(2020, 1, 2, 9, 30, 0), "end": datetime(2020, 1, 2, 15, 30, 0)}], "sunset": [{"start": datetime(2020, 1, 1, 15, 30, 0), "end": datetime(2020, 1, 1, 17, 30, 0)},{"start": datetime(2020, 1, 2, 15, 30, 0), "end": datetime(2020, 1, 2, 17, 30, 0)}], "night": [{"start": datetime(2020, 1, 1, 17, 30, 0), "end": datetime(2020, 1, 2, 7, 30, 0)}, {"start": datetime(2020, 1, 2, 17, 30, 0), "end": datetime(2020, 1, 3, 7, 30, 0)}]}, @@ -295,20 +296,53 @@ class TestSchedulingConstraints(unittest.TestCase): "day": [{"start": datetime(2020, 1, 1, 9, 45, 0), "end": datetime(2020, 1, 1, 15, 45, 0)}, {"start": datetime(2020, 1, 2, 9, 45, 0), "end": datetime(2020, 1, 2, 15, 45, 0)}], "sunset": [{"start": datetime(2020, 1, 1, 15, 45, 0), "end": datetime(2020, 1, 1, 17, 45, 0)}, {"start": datetime(2020, 1, 2, 15, 45, 0), "end": datetime(2020, 1, 2, 17, 45, 0)}], "night": [{"start": datetime(2020, 1, 1, 17, 45, 0), "end": datetime(2020, 1, 2, 7, 45, 0)}, {"start": datetime(2020, 1, 2, 17, 45, 0), "end": datetime(2020, 1, 3, 7, 45, 0)}]}} + + # variant for timestamp before sunrise, which returns the previous night + self.sunrise_data_early_night = { + 'CS001': {"sunrise": [{"start": datetime(2020, 1, 1, 7, 30, 0), "end": datetime(2020, 1, 1, 9, 30, 0)}, {"start": datetime(2020, 1, 2, 7, 30, 0), "end": datetime(2020, 1, 2, 9, 30, 0)}], + "day": [{"start": datetime(2020, 1, 1, 9, 30, 0), "end": datetime(2020, 1, 1, 15, 30, 0)}, {"start": datetime(2020, 1, 2, 9, 30, 0), "end": datetime(2020, 1, 2, 15, 30, 0)}], + "sunset": [{"start": datetime(2020, 1, 1, 15, 30, 0), "end": datetime(2020, 1, 1, 17, 30, 0)},{"start": datetime(2020, 1, 2, 15, 30, 0), "end": datetime(2020, 1, 2, 17, 30, 0)}], + "night": [{"start": datetime(2019, 12, 31, 17, 30, 0), "end": datetime(2020, 1, 1, 7, 30, 0)}, {"start": datetime(2020, 1, 1, 17, 30, 0), "end": datetime(2020, 1, 2, 7, 30, 0)}]}} + + + # constraint checker requests lower and upper bound, so we need some variants for various cases + self.sunrise_data_early_night_early_night = { + 'CS001': {"sunrise": [{"start": datetime(2020, 1, 1, 7, 30, 0), "end": datetime(2020, 1, 1, 9, 30, 0)}, {"start": datetime(2020, 1, 1, 7, 30, 0), "end": datetime(2020, 1, 1, 9, 30, 0)}], + "day": [{"start": datetime(2020, 1, 1, 9, 30, 0), "end": datetime(2020, 1, 1, 15, 30, 0)}, {"start": datetime(2020, 1, 1, 9, 30, 0), "end": datetime(2020, 1, 1, 15, 30, 0)}], + "sunset": [{"start": datetime(2020, 1, 1, 15, 30, 0), "end": datetime(2020, 1, 1, 17, 30, 0)},{"start": datetime(2020, 1, 1, 15, 30, 0), "end": datetime(2020, 1, 1, 17, 30, 0)}], + "night": [{"start": datetime(2019, 12, 31, 17, 30, 0), "end": datetime(2020, 1, 2, 7, 30, 0)}, {"start": datetime(2019, 12, 31, 17, 30, 0), "end": datetime(2020, 1, 1, 7, 30, 0)}]}} + + self.sunrise_data_early_night_late_night = { + 'CS001': {"sunrise": [{"start": datetime(2020, 1, 1, 7, 30, 0), "end": datetime(2020, 1, 1, 9, 30, 0)}, {"start": datetime(2020, 1, 1, 7, 30, 0), "end": datetime(2020, 1, 1, 9, 30, 0)}], + "day": [{"start": datetime(2020, 1, 1, 9, 30, 0), "end": datetime(2020, 1, 1, 15, 30, 0)}, {"start": datetime(2020, 1, 1, 9, 30, 0), "end": datetime(2020, 1, 1, 15, 30, 0)}], + "sunset": [{"start": datetime(2020, 1, 1, 15, 30, 0), "end": datetime(2020, 1, 1, 17, 30, 0)},{"start": datetime(2020, 1, 1, 15, 30, 0), "end": datetime(2020, 1, 1, 17, 30, 0)}], + "night": [{"start": datetime(2019, 12, 31, 17, 30, 0), "end": datetime(2020, 1, 2, 7, 30, 0)}, {"start": datetime(2020, 1, 1, 17, 30, 0), "end": datetime(2020, 1, 2, 7, 30, 0)}]}} + + self.sunrise_data_late_night_late_night = { + 'CS001': {"sunrise": [{"start": datetime(2020, 1, 1, 7, 30, 0), "end": datetime(2020, 1, 1, 9, 30, 0)}, {"start": datetime(2020, 1, 1, 7, 30, 0), "end": datetime(2020, 1, 1, 9, 30, 0)}], + "day": [{"start": datetime(2020, 1, 1, 9, 30, 0), "end": datetime(2020, 1, 1, 15, 30, 0)}, {"start": datetime(2020, 1, 1, 9, 30, 0), "end": datetime(2020, 1, 1, 15, 30, 0)}], + "sunset": [{"start": datetime(2020, 1, 1, 15, 30, 0), "end": datetime(2020, 1, 1, 17, 30, 0)},{"start": datetime(2020, 1, 1, 15, 30, 0), "end": datetime(2020, 1, 1, 17, 30, 0)}], + "night": [{"start": datetime(2020, 1, 1, 17, 30, 0), "end": datetime(2020, 1, 2, 7, 30, 0)}, {"start": datetime(2020, 1, 1, 17, 30, 0), "end": datetime(2020, 1, 2, 7, 30, 0)}]}} + + self.sunrise_data_late_night_early_night_next_day = { + 'CS001': {"sunrise": [{"start": datetime(2020, 1, 1, 7, 30, 0), "end": datetime(2020, 1, 1, 9, 30, 0)}, {"start": datetime(2020, 1, 2, 7, 30, 0), "end": datetime(2020, 1, 2, 9, 30, 0)}], + "day": [{"start": datetime(2020, 1, 1, 9, 30, 0), "end": datetime(2020, 1, 1, 15, 30, 0)}, {"start": datetime(2020, 1, 2, 9, 30, 0), "end": datetime(2020, 1, 2, 15, 30, 0)}], + "sunset": [{"start": datetime(2020, 1, 1, 15, 30, 0), "end": datetime(2020, 1, 1, 17, 30, 0)},{"start": datetime(2020, 1, 2, 15, 30, 0), "end": datetime(2020, 1, 2, 17, 30, 0)}], + "night": [{"start": datetime(2020, 1, 1, 17, 30, 0), "end": datetime(2020, 1, 2, 7, 30, 0)}, {"start": datetime(2020, 1, 1, 17, 30, 0), "end": datetime(2020, 1, 2, 7, 30, 0)}]}} + + self.sunrise_data_late_night_late_night_next_day = { + 'CS001': {"sunrise": [{"start": datetime(2020, 1, 1, 7, 30, 0), "end": datetime(2020, 1, 1, 9, 30, 0)}, {"start": datetime(2020, 1, 2, 7, 30, 0), "end": datetime(2020, 1, 2, 9, 30, 0)}], + "day": [{"start": datetime(2020, 1, 1, 9, 30, 0), "end": datetime(2020, 1, 1, 15, 30, 0)}, {"start": datetime(2020, 1, 2, 9, 30, 0), "end": datetime(2020, 1, 2, 15, 30, 0)}], + "sunset": [{"start": datetime(2020, 1, 1, 15, 30, 0), "end": datetime(2020, 1, 1, 17, 30, 0)},{"start": datetime(2020, 1, 2, 15, 30, 0), "end": datetime(2020, 1, 2, 17, 30, 0)}], + "night": [{"start": datetime(2020, 1, 1, 17, 30, 0), "end": datetime(2020, 1, 2, 7, 30, 0)}, {"start": datetime(2020, 1, 2, 17, 30, 0), "end": datetime(2020, 1, 3, 7, 30, 0)}]}} + + self.sunrise_patcher = mock.patch('lofar.sas.tmss.services.scheduling.constraints.template_constraints_v1.timestamps_and_stations_to_sun_rise_and_set') self.sunrise_mock = self.sunrise_patcher.start() self.sunrise_mock.return_value = self.sunrise_data self.addCleanup(self.sunrise_patcher.stop) - self.distance_data = { - "sun": {datetime(2020, 1, 1, 10, 0, 0): Angle("0.3rad"), datetime(2020, 1, 1, 12, 0, 0): Angle("0.35rad")}, - "moon": {datetime(2020, 1, 1, 10, 0, 0): Angle("0.2rad"), datetime(2020, 1, 1, 12, 0, 0): Angle("0.25rad")}, - "jupiter": {datetime(2020, 1, 1, 10, 0, 0): Angle("0.1rad"), datetime(2020, 1, 1, 12, 0, 0): Angle("0.15rad")} - } - self.distance_patcher = mock.patch('lofar.sas.tmss.services.scheduling.constraints.template_constraints_v1.coordinates_and_timestamps_to_separation_from_bodies') - self.distance_mock = self.distance_patcher.start() - self.distance_mock.return_value = self.distance_data - self.addCleanup(self.distance_patcher.stop) + # require_day def test_get_earliest_possible_start_time_with_daytime_constraint_returns_day_start(self): self.scheduling_unit_blueprint.draft.scheduling_constraints_doc['daily']['require_day'] = True @@ -346,13 +380,186 @@ class TestSchedulingConstraints(unittest.TestCase): returned_time = get_earliest_possible_start_time(self.scheduling_unit_blueprint, timestamp) self.assertEqual(returned_time, self.sunrise_data['CS001']['day'][1]['start']) - # todo: add tests for can_run_within_timewindow_with_daily_constraints + def test_can_run_within_timewindow_with_daytime_constraint_returns_true(self): + self.scheduling_unit_blueprint.draft.scheduling_constraints_doc['sky']['min_distance'] = {} # remove sky constraint + self.scheduling_unit_blueprint.draft.scheduling_constraints_doc['daily']['require_day'] = True + self.scheduling_unit_blueprint.save() - # todo: add more daytime checks with 255 + self.sunrise_mock.return_value = self.sunrise_data_late_night_late_night + lower_bound = datetime(2020, 1, 1, 10, 0, 0) + upper_bound = datetime(2020, 1, 1, 15, 0, 0) + self.assertTrue(can_run_within_timewindow(self.scheduling_unit_blueprint, lower_bound, upper_bound)) - # todo: add nighttime checks with 254 + def test_can_run_within_timewindow_with_daytime_constraint_returns_false_when_not_daytime(self): + self.scheduling_unit_blueprint.draft.scheduling_constraints_doc['sky']['min_distance'] = {} # remove sky constraint + self.scheduling_unit_blueprint.draft.scheduling_constraints_doc['daily']['require_day'] = True + self.scheduling_unit_blueprint.save() + + self.sunrise_mock.return_value = self.sunrise_data_late_night_late_night + lower_bound = datetime(2020, 1, 1, 20, 0, 0) + upper_bound = datetime(2020, 1, 1, 23, 0, 0) + self.assertFalse(can_run_within_timewindow(self.scheduling_unit_blueprint, lower_bound, upper_bound)) + + def test_can_run_within_timewindow_with_daytime_constraint_returns_false_when_partially_not_daytime(self): + self.scheduling_unit_blueprint.draft.scheduling_constraints_doc['sky']['min_distance'] = {} # remove sky constraint + self.scheduling_unit_blueprint.draft.scheduling_constraints_doc['daily']['require_day'] = True + self.scheduling_unit_blueprint.save() + + self.sunrise_mock.return_value = self.sunrise_data_late_night_late_night + lower_bound = datetime(2020, 1, 1, 10, 0, 0) + upper_bound = datetime(2020, 1, 1, 18, 0, 0) + self.assertFalse(can_run_within_timewindow(self.scheduling_unit_blueprint, lower_bound, upper_bound)) + + self.sunrise_mock.return_value = self.sunrise_data_early_night_late_night + lower_bound = datetime(2020, 1, 1, 8, 0, 0) + upper_bound = datetime(2020, 1, 1, 10, 0, 0) + self.assertFalse(can_run_within_timewindow(self.scheduling_unit_blueprint, lower_bound, upper_bound)) + + # require_night + + def test_get_earliest_possible_start_time_with_nighttime_constraint_returns_night_start(self): + self.scheduling_unit_blueprint.draft.scheduling_constraints_doc['daily']['require_night'] = True + self.scheduling_unit_blueprint.save() + timestamp = datetime(2020, 1, 1, 14, 0, 0) + returned_time = get_earliest_possible_start_time(self.scheduling_unit_blueprint, timestamp) + self.assertEqual(returned_time, self.sunrise_data['CS001']['night'][0]['start']) + + def test_get_earliest_possible_start_time_with_nighttime_constraint_returns_night_start_of_latest_station(self): + self.scheduling_unit_blueprint.requirements_doc['tasks']['Observation']['specifications_doc']['stations'] = ['CS001', 'DE601'] + self.scheduling_unit_blueprint.draft.scheduling_constraints_doc['daily']['require_night'] = True + self.scheduling_unit_blueprint.save() + timestamp = datetime(2020, 1, 1, 14, 0, 0) + returned_time = get_earliest_possible_start_time(self.scheduling_unit_blueprint, timestamp) + self.assertEqual(returned_time, self.sunrise_data['DE601']['night'][0]['start']) + + def test_get_earliest_possible_start_time_with_nighttime_constraint_returns_timestamp(self): + self.scheduling_unit_blueprint.draft.scheduling_constraints_doc['daily']['require_night'] = True + self.scheduling_unit_blueprint.save() + + # late night + timestamp = datetime(2020, 1, 1, 23, 0, 0) + returned_time = get_earliest_possible_start_time(self.scheduling_unit_blueprint, timestamp) + self.assertEqual(returned_time, timestamp) + + # early night + self.sunrise_mock.return_value = self.sunrise_data_early_night + timestamp = datetime(2020, 1, 1, 3, 0, 0) + returned_time = get_earliest_possible_start_time(self.scheduling_unit_blueprint, timestamp) + self.assertEqual(returned_time, timestamp) + + def test_get_earliest_possible_start_time_with_nighttime_constraint_returns_next_night_start_when_obs_does_not_fit(self): + self.scheduling_unit_blueprint.draft.scheduling_constraints_doc['daily']['require_night'] = True + self.scheduling_unit_blueprint.save() + + # early night + self.sunrise_mock.return_value = self.sunrise_data_early_night + timestamp = datetime(2020, 1, 1, 6, 0, 0) + returned_time = get_earliest_possible_start_time(self.scheduling_unit_blueprint, timestamp) + self.assertEqual(returned_time, self.sunrise_data_early_night['CS001']['night'][1]['start']) + + def test_can_run_within_timewindow_with_nighttime_constraint_returns_true(self): + self.scheduling_unit_blueprint.draft.scheduling_constraints_doc['sky']['min_distance'] = {} # remove sky constraint + self.scheduling_unit_blueprint.draft.scheduling_constraints_doc['daily']['require_night'] = True + self.scheduling_unit_blueprint.save() + + # early night + self.sunrise_mock.return_value = self.sunrise_data_early_night_early_night + lower_bound = datetime(2020, 1, 1, 1, 0, 0) + upper_bound = datetime(2020, 1, 1, 3, 0, 0) + self.assertTrue(can_run_within_timewindow(self.scheduling_unit_blueprint, lower_bound, upper_bound)) + + # late night + self.sunrise_mock.return_value = self.sunrise_data_late_night_late_night + lower_bound = datetime(2020, 1, 1, 20, 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)) + + # night-night next day + self.sunrise_mock.return_value = self.sunrise_data_late_night_early_night_next_day + lower_bound = datetime(2020, 1, 1, 23, 0, 0) + upper_bound = datetime(2020, 1, 2, 3, 0, 0) + self.assertTrue(can_run_within_timewindow(self.scheduling_unit_blueprint, lower_bound, upper_bound)) + + def test_can_run_within_timewindow_with_nighttime_constraint_returns_false_when_not_nighttime(self): + self.scheduling_unit_blueprint.draft.scheduling_constraints_doc['sky']['min_distance'] = {} # remove sky constraint + self.scheduling_unit_blueprint.draft.scheduling_constraints_doc['daily']['require_night'] = True + self.scheduling_unit_blueprint.save() + + self.sunrise_mock.return_value = self.sunrise_data_late_night_late_night + lower_bound = datetime(2020, 1, 1, 10, 0, 0) + upper_bound = datetime(2020, 1, 1, 14, 0, 0) + self.assertFalse(can_run_within_timewindow(self.scheduling_unit_blueprint, lower_bound, upper_bound)) + + def test_can_run_within_timewindow_with_nighttime_constraint_returns_false_when_partially_not_nighttime(self): + self.scheduling_unit_blueprint.draft.scheduling_constraints_doc['sky']['min_distance'] = {} # remove sky constraint + self.scheduling_unit_blueprint.draft.scheduling_constraints_doc['daily']['require_night'] = True + self.scheduling_unit_blueprint.save() + + # night-day next day + self.sunrise_mock.return_value = self.sunrise_data_late_night_late_night_next_day + lower_bound = datetime(2020, 1, 1, 23, 0, 0) + upper_bound = datetime(2020, 1, 2, 10, 0, 0) + self.assertFalse(can_run_within_timewindow(self.scheduling_unit_blueprint, lower_bound, upper_bound)) + + # day-night next day + self.sunrise_mock.return_value = self.sunrise_data_late_night_early_night_next_day + lower_bound = datetime(2020, 1, 1, 14, 0, 0) + upper_bound = datetime(2020, 1, 2, 3, 0, 0) + self.assertFalse(can_run_within_timewindow(self.scheduling_unit_blueprint, lower_bound, upper_bound)) + + # day-night same day + self.sunrise_mock.return_value = self.sunrise_data_late_night_late_night + lower_bound = datetime(2020, 1, 1, 14, 0, 0) + upper_bound = datetime(2020, 1, 1, 20, 0, 0) + self.assertFalse(can_run_within_timewindow(self.scheduling_unit_blueprint, lower_bound, upper_bound)) + + # night-day same day + self.sunrise_mock.return_value = self.sunrise_data_early_night_late_night + lower_bound = datetime(2020, 1, 1, 3, 0, 0) + upper_bound = datetime(2020, 1, 1, 10, 0, 0) + self.assertFalse(can_run_within_timewindow(self.scheduling_unit_blueprint, lower_bound, upper_bound)) + + # day-night-day + self.sunrise_mock.return_value = self.sunrise_data_late_night_late_night_next_day + lower_bound = datetime(2020, 1, 1, 14, 0, 0) + upper_bound = datetime(2020, 1, 2, 10, 0, 0) + self.assertFalse(can_run_within_timewindow(self.scheduling_unit_blueprint, lower_bound, upper_bound)) + + # night-day-night + self.sunrise_mock.return_value = self.sunrise_data_early_night_late_night + lower_bound = datetime(2020, 1, 1, 3, 0, 0) + upper_bound = datetime(2020, 1, 1, 23, 0, 0) + self.assertFalse(can_run_within_timewindow(self.scheduling_unit_blueprint, lower_bound, upper_bound)) + + # todo: avoid_twilight checks / TMSS-256 + + +class TestSkyConstraints(unittest.TestCase): + ''' + Tests for the constraint checkers used in dynamic scheduling + ''' + + def setUp(self) -> None: + # scheduling unit + self.obs_duration = 120 * 60 + scheduling_set = models.SchedulingSet.objects.create(**SchedulingSet_test_data()) + scheduling_unit_draft = TestDynamicScheduling.create_simple_observation_scheduling_unit("scheduling unit for ...%s" % self._testMethodName[30:], + scheduling_set=scheduling_set, + obs_duration=self.obs_duration) + self.scheduling_unit_blueprint = create_task_blueprints_and_subtasks_from_scheduling_unit_draft(scheduling_unit_draft) + + # mock out conversions for speedup and assertable timestamps + self.distance_data = { + "sun": {datetime(2020, 1, 1, 10, 0, 0): Angle("0.3rad"), datetime(2020, 1, 1, 12, 0, 0): Angle("0.35rad")}, + "moon": {datetime(2020, 1, 1, 10, 0, 0): Angle("0.2rad"), datetime(2020, 1, 1, 12, 0, 0): Angle("0.25rad")}, + "jupiter": {datetime(2020, 1, 1, 10, 0, 0): Angle("0.1rad"), datetime(2020, 1, 1, 12, 0, 0): Angle("0.15rad")} + } + self.distance_patcher = mock.patch('lofar.sas.tmss.services.scheduling.constraints.template_constraints_v1.coordinates_and_timestamps_to_separation_from_bodies') + self.distance_mock = self.distance_patcher.start() + self.distance_mock.return_value = self.distance_data + self.addCleanup(self.distance_patcher.stop) - # todo: add twilight checks with 256 + # min_distance def test_can_run_within_timewindow_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} diff --git a/SAS/TMSS/src/tmss/tmssapp/conversions.py b/SAS/TMSS/src/tmss/tmssapp/conversions.py index af5d004637c17f20118bd660e4e761b22fef288a..335b8937493b5c3e26fa9f0a80b798bee31107c0 100644 --- a/SAS/TMSS/src/tmss/tmssapp/conversions.py +++ b/SAS/TMSS/src/tmss/tmssapp/conversions.py @@ -30,7 +30,9 @@ SUN_SET_RISE_PRECISION = 30 # n_grid_points; higher is more precise but very co @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: """ - compute sunrise, sunset, day and night of the given stations at the given timestamps + Compute sunrise, sunset, day and night of the given stations at the given timestamps. + The day/sunrise/sunset is always on the date of the timestamp. + 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",) :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. @@ -50,24 +52,27 @@ def timestamps_and_stations_to_sun_rise_and_set(timestamps: tuple, stations: tup 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. + # 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(timestamp), which='previous', n_grid_points=SUN_SET_RISE_PRECISION) - if sunrise_start.to_datetime().date() < timestamp.date(): - sunrise_start = observer.sun_rise_time(time=Time(timestamp), horizon=-angle_to_horizon, which='nearest', n_grid_points=SUN_SET_RISE_PRECISION) - if sunrise_start.to_datetime().date() < timestamp.date(): - sunrise_start = observer.sun_rise_time(time=Time(timestamp), horizon=-angle_to_horizon, which='next', n_grid_points=SUN_SET_RISE_PRECISION) - sunrise_end = observer.sun_rise_time(time=Time(timestamp), horizon=angle_to_horizon, which='next', n_grid_points=SUN_SET_RISE_PRECISION) + 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_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=sunrise_end, horizon=-angle_to_horizon, which='next', n_grid_points=SUN_SET_RISE_PRECISION) - sunrise_next_start = observer.sun_rise_time(time=sunset_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) return_dict.setdefault(station, {}).setdefault("sunrise", []).append({"start": sunrise_start.to_datetime(), "end": sunrise_end.to_datetime()}) return_dict[station].setdefault("sunset", []).append({"start": sunset_start.to_datetime(), "end": sunset_end.to_datetime()}) return_dict[station].setdefault("day", []).append({"start": sunrise_end.to_datetime(), "end": sunset_start.to_datetime()}) - return_dict[station].setdefault("night", []).append({"start": sunset_end.to_datetime(), "end": sunrise_next_start.to_datetime()}) + if timestamp >= sunrise_start: + sunrise_next_start = observer.sun_rise_time(time=sunset_end, horizon=-angle_to_horizon, which='next', n_grid_points=SUN_SET_RISE_PRECISION) + return_dict[station].setdefault("night", []).append({"start": sunset_end.to_datetime(), "end": sunrise_next_start.to_datetime()}) + else: + sunset_previous_end = observer.sun_set_time(time=sunrise_start, horizon=-angle_to_horizon, which='previous', n_grid_points=SUN_SET_RISE_PRECISION) + return_dict[station].setdefault("night", []).append({"start": sunset_previous_end.to_datetime(), "end": sunrise_start.to_datetime()}) + return return_dict -# Depending on usage patterns, we should consider refactoring this a little so that we cache on a function with a single timestamp as input. Requests with similar (but not identical) timestamps or bodies currently make no use of cached results for the subset computed in previous requests. +# todo: Depending on usage patterns, we should consider refactoring this a little so that we cache on a function with a single timestamp as input. Requests with similar (but not identical) timestamps or bodies currently make no use of cached results for the subset computed in previous requests. @lru_cache(maxsize=256, typed=False) # does not like lists, so use tuples to allow caching def coordinates_and_timestamps_to_separation_from_bodies(angle1: float, angle2: float, direction_type: str, timestamps: tuple, bodies: tuple) -> dict: """ diff --git a/SAS/TMSS/src/tmss/tmssapp/views.py b/SAS/TMSS/src/tmss/tmssapp/views.py index 3c927861dc1153f3563613e4696b8f7d1f5565f6..928b45a71966142f32f46911a1f77f9bb65e1c6b 100644 --- a/SAS/TMSS/src/tmss/tmssapp/views.py +++ b/SAS/TMSS/src/tmss/tmssapp/views.py @@ -167,7 +167,6 @@ def get_sun_rise_and_set(request): else: 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)) diff --git a/SAS/TMSS/test/t_conversions.py b/SAS/TMSS/test/t_conversions.py index f153900312eac5e6ebab6a268c80386892983c26..18865051aecdd7bd80946e68ef80487e58f8b815 100755 --- a/SAS/TMSS/test/t_conversions.py +++ b/SAS/TMSS/test/t_conversions.py @@ -165,6 +165,38 @@ class UtilREST(unittest.TestCase): response_date = dateutil.parser.parse(r_dict['CS002']['sunrise'][i]['start']).date() self.assertEqual(expected_date, response_date) + def test_util_sun_rise_and_set_returns_correct_date_of_day_sunrise_and_sunset(self): + timestamps = ['2020-01-01T02-00-00'] + r = requests.get(BASE_URL + '/util/sun_rise_and_set?timestamps=%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 values + expected_date = dateutil.parser.parse(timestamps[0]).date() + self.assertEqual(expected_date, dateutil.parser.parse(r_dict['CS002']['sunrise'][0]['start']).date()) + self.assertEqual(expected_date, dateutil.parser.parse(r_dict['CS002']['sunrise'][0]['end']).date()) + self.assertEqual(expected_date, dateutil.parser.parse(r_dict['CS002']['day'][0]['start']).date()) + self.assertEqual(expected_date, dateutil.parser.parse(r_dict['CS002']['day'][0]['end']).date()) + self.assertEqual(expected_date, dateutil.parser.parse(r_dict['CS002']['sunset'][0]['start']).date()) + self.assertEqual(expected_date, dateutil.parser.parse(r_dict['CS002']['sunset'][0]['end']).date()) + + def test_util_sun_rise_and_set_returns_correct_date_of_night(self): + timestamps = ['2020-01-01T02-00-00', '2020-01-01T12-00-00'] + r = requests.get(BASE_URL + '/util/sun_rise_and_set?timestamps=%s' % ','.join(timestamps), auth=AUTH) + self.assertEqual(r.status_code, 200) + r_dict = json.loads(r.content.decode('utf-8')) + + # assert timestamp before sunrise returns night ending on day of timestamp (last night) + expected_date = dateutil.parser.parse(timestamps[0]).date() + response_date = dateutil.parser.parse(r_dict['CS002']['night'][0]['end']).date() + self.assertEqual(expected_date, response_date) + + # assert timestamp after sunrise returns night starting on day of timestamp (next night) + expected_date = dateutil.parser.parse(timestamps[1]).date() + response_date = dateutil.parser.parse(r_dict['CS002']['night'][1]['start']).date() + self.assertEqual(expected_date, response_date) + + 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)