From 1abcfebd2c028819e052937105700633fe877d32 Mon Sep 17 00:00:00 2001 From: Jon Date: Sat, 4 Feb 2023 03:15:09 +0000 Subject: [PATCH] Refactor OCS facility and forms from LCO ones --- tom_alerts/tests/brokers/test_mars.py | 172 -- .../cadences/resume_cadence_after_failure.py | 2 +- .../cadences/retry_failed_observations.py | 2 +- tom_observations/facilities/lco.py | 1724 +++-------------- tom_observations/facilities/ocs.py | 1455 ++++++++++++++ tom_observations/facilities/soar.py | 76 +- tom_observations/facility.py | 23 +- tom_observations/tests/facilities/test_lco.py | 357 +--- tom_observations/tests/facilities/test_ocs.py | 256 +++ .../tests/facilities/test_soar.py | 32 +- tom_observations/tests/test_cadence.py | 10 +- tom_observations/views.py | 2 +- 12 files changed, 2055 insertions(+), 2056 deletions(-) delete mode 100644 tom_alerts/tests/brokers/test_mars.py create mode 100644 tom_observations/facilities/ocs.py create mode 100644 tom_observations/tests/facilities/test_ocs.py diff --git a/tom_alerts/tests/brokers/test_mars.py b/tom_alerts/tests/brokers/test_mars.py deleted file mode 100644 index 79349b300..000000000 --- a/tom_alerts/tests/brokers/test_mars.py +++ /dev/null @@ -1,172 +0,0 @@ -from datetime import datetime -from itertools import islice -import json -from requests import Response - -from django.utils import timezone -from django.test import override_settings, tag, TestCase -from unittest import mock - -from tom_alerts.brokers.mars import MARSBroker -from tom_alerts.alerts import get_service_class -from tom_targets.models import Target -from tom_dataproducts.models import ReducedDatum - -alert1 = { - 'candid': 617122521615015023, - 'candidate': { - 'b': 0.70548695469711, - 'dec': -10.5296018, - 'jd': 2458371.6225231, - 'l': 20.7124513780029, - 'magpsf': 16.321626663208, - 'ra': 276.5843017, - 'rb': 0.990000009536743, - 'wall_time': 'Mon, 10 Sep 2018 02:56:25 GMT', - }, - 'lco_id': 11296149, - 'objectId': 'ZTF18abbkloa', -} - - -@override_settings(TOM_ALERT_CLASSES=['tom_alerts.brokers.mars.MARSBroker']) -class TestMARSBrokerClass(TestCase): - """ Test the functionality of the MARSBroker, we modify the django settings to make sure - it is the only installed broker. - """ - def setUp(self): - self.test_target = Target.objects.create(name='ZTF18aberpsh') - ReducedDatum.objects.create( - source_name='MARS', - source_location=11053318, - target=self.test_target, - data_type='photometry', - timestamp=timezone.now(), - value=12 - ) - alert2 = alert1.copy() - alert2['lco_id'] = 11053318 - alert2['objectId'] = 'ZTF18aberpsh' - self.test_data = [alert1, alert2] - - def test_get_broker_class(self): - self.assertEqual(MARSBroker, get_service_class('MARS')) - - def test_get_invalid_broker(self): - with self.assertRaises(ImportError): - get_service_class('LASAIR') - - @mock.patch('tom_alerts.brokers.mars.requests.get') - def test_fetch_alerts(self, mock_requests_get): - mock_return_data = { - "has_next": "false", - "has_prev": "false", - "pages": 1, - "results": [self.test_data[1]] - } - mock_response = Response() - mock_response._content = str.encode(json.dumps(mock_return_data)) - mock_response.status_code = 200 - mock_requests_get.return_value = mock_response - - alerts, _ = MARSBroker().fetch_alerts({'objectId': 'ZTF18aberpsh'}) - self.assertEqual(self.test_data[1]['objectId'], list(alerts)[0]['objectId']) - - def test_process_reduced_data_with_alert(self): - test_alert = self.test_data[1] - test_alert['prv_candidate'] = [ - { - 'candidate': { - 'jd': 2458372.6225231, - 'magpsf': 13, - 'sigmapsf': 0.5, - 'fid': 1 - } - } - ] - - MARSBroker().process_reduced_data(self.test_target, alert=test_alert) - reduced_data = ReducedDatum.objects.filter(target=self.test_target, source_name='MARS') - self.assertEqual(reduced_data.count(), 2) - - @mock.patch('tom_alerts.brokers.mars.MARSBroker.fetch_alert') - def test_process_reduced_data_no_alert(self, mock_fetch_alert): - self.test_data = self.test_data[1] - self.test_data['prv_candidate'] = [ - { - 'candidate': { - 'jd': 2458372.6225231, - 'magpsf': 13, - 'sigmapsf': 0.5, - 'fid': 1 - } - } - ] - mock_fetch_alert.return_value = self.test_data - - MARSBroker().process_reduced_data(self.test_target) - reduced_data = ReducedDatum.objects.filter(target=self.test_target, source_name='MARS') - self.assertEqual(reduced_data.count(), 2) - - def test_to_target(self): - test_alert = self.test_data[0] - - created_target = MARSBroker().to_target(test_alert) - self.assertEqual(created_target.name, 'ZTF18abbkloa') - - def test_to_generic_alert(self): - test_alert = self.test_data[1] - - created_alert = MARSBroker().to_generic_alert(test_alert) - self.assertEqual(created_alert.name, 'ZTF18aberpsh') - - -@tag('canary') -class TestMARSModuleCanary(TestCase): - def setUp(self): - self.broker = MARSBroker() - self.expected_keys = ['avro', 'candid', 'candidate', 'lco_id', 'objectId', 'publisher'] - self.expected_candidate_keys = ['aimage', 'aimagerat', 'b', 'bimage', 'bimagerat', 'candid', 'chinr', 'chipsf', - 'classtar', 'clrcoeff', 'clrcounc', 'clrmed', 'clrrms', 'dec', 'decnr', - 'deltamaglatest', 'deltamagref', 'diffmaglim', 'distnr', 'distpsnr1', - 'distpsnr2', 'distpsnr3', 'drb', 'drbversion', 'dsdiff', 'dsnrms', 'elong', - 'exptime', 'fid', 'field', 'filter', 'fwhm', 'isdiffpos', 'jd', 'jdendhist', - 'jdendref', 'jdstarthist', 'jdstartref', 'l', 'magap', 'magapbig', 'magdiff', - 'magfromlim', 'maggaia', 'maggaiabright', 'magnr', 'magpsf', 'magzpsci', - 'magzpscirms', 'magzpsciunc', 'mindtoedge', 'nbad', 'ncovhist', 'ndethist', - 'neargaia', 'neargaiabright', 'nframesref', 'nid', 'nmatches', 'nmtchps', - 'nneg', 'objectidps1', 'objectidps2', 'objectidps3', 'pdiffimfilename', 'pid', - 'programid', 'programpi', 'ra', 'ranr', 'rb', 'rbversion', 'rcid', 'rfid', - 'scorr', 'seeratio', 'sgmag1', 'sgmag2', 'sgmag3', 'sgscore1', 'sgscore2', - 'sgscore3', 'sharpnr', 'sigmagap', 'sigmagapbig', 'sigmagnr', 'sigmapsf', - 'simag1', 'simag2', 'simag3', 'sky', 'srmag1', 'srmag2', 'srmag3', 'ssdistnr', - 'ssmagnr', 'ssnamenr', 'ssnrms', 'sumrat', 'szmag1', 'szmag2', 'szmag3', - 'tblid', 'tooflag', 'wall_time', 'xpos', 'ypos', 'zpclrcov', 'zpmed'] - - def test_fetch_alerts(self): - response = self.broker.fetch_alerts({'time__gt': '2018-06-01', 'time__lt': '2018-06-30'}) - - alerts = [] - for alert in islice(response[0], 10): - alerts.append(alert) - self.assertEqual(len(alerts), 10) - - for key in self.expected_keys: - self.assertTrue(key in alerts[0].keys()) - for key in self.expected_candidate_keys: - self.assertTrue(key in alerts[0]['candidate'].keys()) - - def test_fetch_alert(self): - alert = self.broker.fetch_alert(1065519) - - for key in self.expected_keys: - self.assertTrue(key in alert.keys()) - for key in self.expected_candidate_keys: - self.assertTrue(key in alert['candidate'].keys()) - - def test_process_reduced_data(self): - alert = self.broker.fetch_alert(1065519) - t = Target.objects.create(name='test target', ra=1, dec=2) - self.broker.process_reduced_data(t, alert=alert) - self.assertGreaterEqual(ReducedDatum.objects.filter(target=t, timestamp__lte=datetime(2020, 11, 3)).count(), - 526) diff --git a/tom_observations/cadences/resume_cadence_after_failure.py b/tom_observations/cadences/resume_cadence_after_failure.py index c2089a258..cfc663d8a 100644 --- a/tom_observations/cadences/resume_cadence_after_failure.py +++ b/tom_observations/cadences/resume_cadence_after_failure.py @@ -69,7 +69,7 @@ def run(self): # Submission of the new observation to the facility obs_type = last_obs.parameters.get('observation_type') - form = facility.get_form(obs_type)(observation_payload) + form = facility.get_form(obs_type)(data=observation_payload) if form.is_valid(): observation_ids = facility.submit_observation(form.observation_payload()) else: diff --git a/tom_observations/cadences/retry_failed_observations.py b/tom_observations/cadences/retry_failed_observations.py index 9d50ef89e..fc15217bb 100644 --- a/tom_observations/cadences/retry_failed_observations.py +++ b/tom_observations/cadences/retry_failed_observations.py @@ -35,7 +35,7 @@ def run(self): observation_payload, start_keyword=start_keyword, end_keyword=end_keyword ) obs_type = obs.parameters.get('observation_type', None) - form = facility.get_form(obs_type)(observation_payload) + form = facility.get_form(obs_type)(data=observation_payload) form.is_valid() observation_ids = facility.submit_observation(form.observation_payload()) diff --git a/tom_observations/facilities/lco.py b/tom_observations/facilities/lco.py index e09063f22..a185614dd 100644 --- a/tom_observations/facilities/lco.py +++ b/tom_observations/facilities/lco.py @@ -1,498 +1,161 @@ from datetime import datetime, timedelta import logging -import requests from urllib.parse import urlencode -from astropy import units as u -from crispy_forms.bootstrap import AppendedText, PrependedText, Accordion, AccordionGroup, TabHolder, Tab, Alert -from crispy_forms.layout import Column, Div, HTML, Layout, Row, MultiWidgetField, Fieldset, Submit, ButtonHolder +from crispy_forms.bootstrap import AppendedText, PrependedText, AccordionGroup +from crispy_forms.layout import Column, Div, HTML, Layout, Row, MultiWidgetField, Fieldset from dateutil.parser import parse from django import forms from django.conf import settings -from django.core.cache import cache -from tom_common.exceptions import ImproperCredentialsException from tom_observations.cadence import CadenceForm -from tom_observations.facility import BaseRoboticObservationFacility, BaseRoboticObservationForm, get_service_class -from tom_observations.observation_template import GenericTemplateForm +from tom_observations.facilities.ocs import (OCSTemplateBaseForm, OCSFullObservationForm, OCSBaseObservationForm, + OCSConfigurationLayout, OCSInstrumentConfigLayout, OCSSettings, + OCSFacility) from tom_observations.widgets import FilterField -from tom_targets.models import Target, REQUIRED_NON_SIDEREAL_FIELDS, REQUIRED_NON_SIDEREAL_FIELDS_PER_SCHEME logger = logging.getLogger(__name__) -# Determine settings for this module. -try: - LCO_SETTINGS = settings.FACILITIES['LCO'] -except (AttributeError, KeyError): - LCO_SETTINGS = { - 'portal_url': 'https://observe.lco.global', - 'api_key': '', - } - -# Module specific settings. -PORTAL_URL = LCO_SETTINGS['portal_url'] -# Valid observing states at LCO are defined here: https://developers.lco.global/#data-format-definition -VALID_OBSERVING_STATES = [ - 'PENDING', 'COMPLETED', 'WINDOW_EXPIRED', 'CANCELED', 'FAILURE_LIMIT_REACHED', 'NOT_ATTEMPTED' -] -PENDING_OBSERVING_STATES = ['PENDING'] -SUCCESSFUL_OBSERVING_STATES = ['COMPLETED'] -FAILED_OBSERVING_STATES = ['WINDOW_EXPIRED', 'CANCELED', 'FAILURE_LIMIT_REACHED', 'NOT_ATTEMPTED'] -TERMINAL_OBSERVING_STATES = SUCCESSFUL_OBSERVING_STATES + FAILED_OBSERVING_STATES - -# Units of flux and wavelength for converting to Specutils Spectrum1D objects -FLUX_CONSTANT = (1e-15 * u.erg) / (u.cm ** 2 * u.second * u.angstrom) -WAVELENGTH_UNITS = u.angstrom - -# FITS header keywords used for data processing -FITS_FACILITY_KEYWORD = 'ORIGIN' -FITS_FACILITY_KEYWORD_VALUE = 'LCOGT' -FITS_FACILITY_DATE_OBS_KEYWORD = 'DATE-OBS' - -MAX_INSTRUMENT_CONFIGS = 5 -MAX_CONFIGURATIONS = 5 - -# Functions needed specifically for LCO -# Helpers for LCO fields -ipp_value_help = """ - Value between 0.5 to 2.0. - - More information about Intra Proprosal Priority (IPP). +class LCOSettings(OCSSettings): + """ LCO Specific settings + """ + # These class variables describe default help text for a variety of OCS fields. Override + # them as desired for a specific OCS implementation + end_help = """ + Try the + + Target Visibility Calculator. -""" - -observation_mode_help = """ - - More information about Rapid Response mode. - -""" - -optimization_type_help = """ - Scheduling optimization emphasis: Time for ASAP, or Airmass for minimum airmass. -""" - -end_help = """ - Try the - - Target Visibility Calculator. - -""" - -instrument_type_help = """ - - More information about LCO instruments. - -""" - -exposure_time_help = """ - Try the - - online Exposure Time Calculator. - -""" - -max_airmass_help = """ - Advice on - - setting the airmass limit. - -""" - -max_lunar_phase_help = """ - Value between 0 (new moon) and 1 (full moon). -""" - -fractional_ephemeris_rate_help = """ - Fractional Ephemeris Rate. Will track with target motion if left blank.
- Caution: Setting any value other than "1" will cause the target to slowly drift from the central - pointing. This could result in the target leaving the field of view for rapid targets, and/or - long observation blocks.
-""" - -static_cadencing_help = """ - Static cadence parameters. Leave blank if no cadencing is desired. - For information on static cadencing with LCO, - - check the Observation Portal getting started guide, starting on page 27. - -""" - -muscat_exposure_mode_help = """ - Synchronous syncs the start time of exposures on all 4 cameras while asynchronous takes - exposures as quickly as possible on each camera. -""" - -repeat_duration_help = """ - The requested duration for this configuration to be repeated within. - Only applicable to * Sequence configuration types. -""" - - -def make_request(*args, **kwargs): - response = requests.request(*args, **kwargs) - if 401 <= response.status_code <= 403: - raise ImproperCredentialsException('LCO: ' + str(response.content)) - elif 400 == response.status_code: - raise forms.ValidationError(f'LCO: {str(response.content)}') - response.raise_for_status() - return response - - -class LCOBaseForm(forms.Form): - """ The LCOBaseForm assumes nothing of fields, and just adds helper methods for getting - data from the LCO portal to other form subclasses. """ - def target_group_choices(self): - target_id = self.data.get('target_id') - if not target_id: - target_id = self.initial.get('target_id') - try: - target_name = Target.objects.get(pk=target_id).name - group_targets = Target.objects.filter(targetlist__targets__pk=target_id).exclude( - pk=target_id).order_by('name').distinct().values_list('pk', 'name') - return [(target_id, target_name)] + list(group_targets) - except Target.DoesNotExist: - return [] - - @staticmethod - def _get_instruments(): - cached_instruments = cache.get('lco_instruments') - - if not cached_instruments: - response = make_request( - 'GET', - PORTAL_URL + '/api/instruments/', - headers={'Authorization': 'Token {0}'.format(LCO_SETTINGS['api_key'])} - ) - cached_instruments = {k: v for k, v in response.json().items() if 'SOAR' not in k} - cache.set('lco_instruments', cached_instruments, 3600) - - return cached_instruments - - @staticmethod - def instrument_to_type(instrument_type): - if 'FLOYDS' in instrument_type: - return 'SPECTRUM' - elif 'NRES' in instrument_type: - return 'NRES_SPECTRUM' - else: - return 'EXPOSE' - - def get_instruments(self): - return LCOBaseForm._get_instruments() - - def instrument_choices(self): - return sorted([(k, v['name']) for k, v in self.get_instruments().items()], key=lambda inst: inst[1]) - - def mode_choices(self, mode_type, use_code_only=False): - return sorted(set([ - (f['code'], f['code'] if use_code_only else f['name']) for ins in self.get_instruments().values() for f in - ins.get('modes', {}).get(mode_type, {}).get('modes', []) - ]), key=lambda filter_tuple: filter_tuple[1]) - - def filter_choices(self, use_code_only=False): - return sorted(set([ - (f['code'], f['code'] if use_code_only else f['name']) for ins in self.get_instruments().values() for f in - ins['optical_elements'].get('filters', []) + ins['optical_elements'].get('slits', []) if f['schedulable'] - ]), key=lambda filter_tuple: filter_tuple[1]) - - def filter_choices_for_group(self, oe_group, use_code_only=False): - return sorted(set([ - (f['code'], f['code'] if use_code_only else f['name']) for ins in self.get_instruments().values() for f in - ins['optical_elements'].get(oe_group, []) if f.get('schedulable') - ]), key=lambda filter_tuple: filter_tuple[1]) - - def get_optical_element_groups(self): - oe_groups = set() - for instrument in self.get_instruments().values(): - for oe_group in instrument['optical_elements'].keys(): - oe_groups.add(oe_group.rstrip('s')) - return sorted(oe_groups) - - def configuration_type_choices(self): - all_config_types = set() - for instrument in self.get_instruments().values(): - config_types = instrument.get('configuration_types', {}).values() - all_config_types.update( - {(config_type.get('code'), config_type.get('name')) - for config_type in config_types if config_type.get('schedulable')} - ) - return sorted(all_config_types, key=lambda config_type: config_type[1]) - - @staticmethod - def proposal_choices(): - response = make_request( - 'GET', - PORTAL_URL + '/api/profile/', - headers={'Authorization': 'Token {0}'.format(LCO_SETTINGS['api_key'])} - ) - choices = [] - for p in response.json()['proposals']: - if p['current']: - choices.append((p['id'], '{} ({})'.format(p['title'], p['id']))) - return choices + instrument_type_help = """ + + More information about LCO instruments. + + """ + max_airmass_help = """ + Advice on + + setting the airmass limit. + + """ -class LCOTemplateBaseForm(LCOBaseForm): - ipp_value = forms.FloatField() - exposure_count = forms.IntegerField(min_value=1) - exposure_time = forms.FloatField(min_value=0.1) - max_airmass = forms.FloatField() + exposure_time_help = """ + Try the + + online Exposure Time Calculator. + + """ - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.fields['proposal'] = forms.ChoiceField(choices=self.proposal_choices()) - self.fields['filter'] = forms.ChoiceField(choices=self.filter_choices()) - self.fields['instrument_type'] = forms.ChoiceField(choices=self.instrument_choices()) + fractional_ephemeris_rate_help = """ + Fractional Ephemeris Rate. Will track with target motion if left blank.
+ Caution: Setting any value other than "1" will cause the target to slowly drift from the central + pointing. This could result in the target leaving the field of view for rapid targets, and/or + long observation blocks.
+ """ + muscat_exposure_mode_help = """ + Synchronous syncs the start time of exposures on all 4 cameras while asynchronous takes + exposures as quickly as possible on each camera. + """ -class AdvancedExpansionsLayout(Layout): - def __init__(self, form_name, *args, **kwargs): - super().__init__( - Accordion( - *self._get_accordion_group(form_name) - ) - ) + repeat_duration_help = """ + The requested duration for this configuration to be repeated within. + Only applicable to * Sequence configuration types. + """ - def _get_accordion_group(self, form_name): - return ( - AccordionGroup( - 'Cadence / Dither / Mosaic', - Alert( - content="""Using the following sections each result in expanding portions of the Request - on submission. You should only combine these if you know what you are doing. - """, - css_class='alert-warning' - ), - TabHolder( - Tab('Cadence', - Div( - HTML(f'''

{static_cadencing_help}

'''), - ), - Div( - Div( - 'period', - css_class='col' - ), - Div( - 'jitter', - css_class='col' - ), - css_class='form-row' - ), - css_id=f'{form_name}_cadence' - ), - Tab('Dithering', - Alert( - content="Dithering will only be applied if you have a single Configuration specified.", - css_class='alert-warning' - ), - Div( - Div( - 'dither_pattern', - css_class='col' - ), - Div( - 'dither_num_points', - css_class='col' - ), - css_class='form-row' - ), - Div( - Div( - 'dither_point_spacing', - css_class='col' - ), - Div( - 'dither_line_spacing', - css_class='col' - ), - css_class='form-row' - ), - Div( - Div( - 'dither_num_rows', - css_class='col' - ), - Div( - 'dither_num_columns', - css_class='col' - ), - css_class='form-row' - ), - Div( - Div( - 'dither_orientation', - css_class='col' - ), - Div( - 'dither_center', - css_class='col' - ), - css_class='form-row' - ), - css_id=f'{form_name}_dithering' - ), - Tab('Mosaicing', - Alert( - content="Mosaicing will only be applied if you have a single Configuration specified.", - css_class='alert-warning' - ), - Div( - Div( - 'mosaic_pattern', - css_class='col' - ), - Div( - 'mosaic_num_points', - css_class='col' - ), - css_class='form-row' - ), - Div( - Div( - 'mosaic_point_overlap', - css_class='col' - ), - Div( - 'mosaic_line_overlap', - css_class='col' - ), - css_class='form-row' - ), - Div( - Div( - 'mosaic_num_rows', - css_class='col' - ), - Div( - 'mosaic_num_columns', - css_class='col' - ), - css_class='form-row' - ), - Div( - Div( - 'mosaic_orientation', - css_class='col' - ), - Div( - 'mosaic_center', - css_class='col' - ), - css_class='form-row' - ), - css_id=f'{form_name}_mosaicing' - ) - ), - active=False, - css_id=f'{form_name}-expansions-group' - ) - ) + static_cadencing_help = """ + Static cadence parameters. Leave blank if no cadencing is desired. + For information on static cadencing with LCO, + + check the Observation Portal getting started guide, starting on page 27. + + """ + def __init__(self, facility_name='LCO'): + super().__init__(facility_name=facility_name) -class ConfigurationLayout(Layout): - def __init__(self, form_name, instrument_config_layout_class, oe_groups, *args, **kwargs): - self.form_name = form_name - self.instrument_config_layout_class = instrument_config_layout_class - super().__init__( - Div( - HTML('''

Configurations:

''') - ), - TabHolder( - *self._get_config_tabs(oe_groups, MAX_CONFIGURATIONS) - ) - ) + def get_fits_facility_header_value(self): + """ Should return the expected value in the fits facility header for data from this facility + """ + return 'LCOGT' - def _get_config_tabs(self, oe_groups, num_tabs): - tabs = [] - for i in range(num_tabs): - tabs.append( - Tab(f'{i+1}', - *self._get_config_layout(i+1, oe_groups), - css_id=f'{self.form_name}_config_{i+1}' - ), - ) - return tuple(tabs) + def get_sites(self): + return { + 'Siding Spring': { + 'sitecode': 'coj', + 'latitude': -31.272, + 'longitude': 149.07, + 'elevation': 1116 + }, + 'Sutherland': { + 'sitecode': 'cpt', + 'latitude': -32.38, + 'longitude': 20.81, + 'elevation': 1804 + }, + 'Teide': { + 'sitecode': 'tfn', + 'latitude': 20.3, + 'longitude': -16.511, + 'elevation': 2390 + }, + 'Cerro Tololo': { + 'sitecode': 'lsc', + 'latitude': -30.167, + 'longitude': -70.804, + 'elevation': 2198 + }, + 'McDonald': { + 'sitecode': 'elp', + 'latitude': 30.679, + 'longitude': -104.015, + 'elevation': 2027 + }, + 'Haleakala': { + 'sitecode': 'ogg', + 'latitude': 20.706, + 'longitude': -156.258, + 'elevation': 3065 + } + } - def _get_config_layout(self, instance, oe_groups): - return ( - Alert( - content="""When using multiple configurations, ensure the instrument types are all - available on the same telescope class. - """, - css_class='alert-warning' - ), - Div( - Div( - f'c_{instance}_instrument_type', - css_class='col' - ), - Div( - f'c_{instance}_configuration_type', - css_class='col' - ), - css_class='form-row' - ), - Div( - Div( - f'c_{instance}_repeat_duration', - css_class='col' - ), - css_class='form-row' - ), - *self._get_target_override(instance), - Accordion( - AccordionGroup('Instrument Configurations', - self.instrument_config_layout_class(self.form_name, instance, oe_groups), - css_id=f'{self.form_name}-c-{instance}-instrument-configs' - ), - AccordionGroup('Constraints', - Div( - Div( - f'c_{instance}_max_airmass', - css_class='col' - ), - css_class='form-row' - ), - Div( - Div( - f'c_{instance}_min_lunar_distance', - css_class='col' - ), - Div( - f'c_{instance}_max_lunar_phase', - css_class='col' - ), - css_class='form-row' - ), - ), - AccordionGroup('Fractional Ephemeris Rate', - Div( - HTML(f'''

{fractional_ephemeris_rate_help}

''') - ), - Div( - f'c_{instance}_fractional_ephemeris_rate', - css_class='form-col' - ) - ), - ) - ) + def get_weather_urls(self): + return { + 'code': self.facility_name, + 'sites': [ + { + 'code': site['sitecode'], + 'weather_url': f'https://weather.lco.global/#/{site["sitecode"]}' + } + for site in self.get_sites().values()] + } - def _get_target_override(self, instance): - if instance == 1: - return () - else: - return ( - Div( - f'c_{instance}_target_override', - css_class='form-row' - ) - ) +class LCOTemplateBaseForm(OCSTemplateBaseForm): + def all_optical_element_choices(self, use_code_only=False): + return sorted(set([ + (f['code'], f['code'] if use_code_only else f['name']) for ins in self.get_instruments().values() for f in + ins['optical_elements'].get('filters', []) + ins['optical_elements'].get('slits', []) if f['schedulable'] + ]), key=lambda filter_tuple: filter_tuple[1]) -class MuscatConfigurationLayout(ConfigurationLayout): +class LCOConfigurationLayout(OCSConfigurationLayout): + def get_final_accordion_items(self, instance): + """ Override in the subclasses to add items at the end of the accordion group + """ + return AccordionGroup('Fractional Ephemeris Rate', + Div( + HTML(f'''

{self.facility_settings.fractional_ephemeris_rate_help}

''') + ), + Div( + f'c_{instance}_fractional_ephemeris_rate', + css_class='form-col' + ) + ) + + +class MuscatConfigurationLayout(LCOConfigurationLayout): def _get_target_override(self, instance): if instance == 1: return ( @@ -517,7 +180,7 @@ def _get_target_override(self, instance): ) -class SpectralConfigurationLayout(ConfigurationLayout): +class SpectralConfigurationLayout(LCOConfigurationLayout): def _get_target_override(self, instance): if instance == 1: return ( @@ -542,76 +205,7 @@ def _get_target_override(self, instance): ) -class InstrumentConfigLayout(Layout): - def __init__(self, form_name, config_instance, oe_groups, *args, **kwargs): - self.form_name = form_name - super().__init__( - TabHolder( - *self._get_ic_tabs(config_instance, oe_groups, MAX_INSTRUMENT_CONFIGS) - ) - ) - - def _get_ic_tabs(self, config_instance, oe_groups, num_tabs): - tabs = [] - for i in range(num_tabs): - tabs.append( - Tab(f'{i+1}', - *self._get_ic_layout(config_instance, i+1, oe_groups), - css_id=f'{self.form_name}_c_{config_instance}_ic_{i+1}' - ), - ) - return tuple(tabs) - - def _get_oe_groups_layout(self, config_instance, instance, oe_groups): - oe_groups_layout = [] - for oe_group1, oe_group2 in zip(*[iter(oe_groups)]*2): - oe_groups_layout.append( - Div( - Div( - f'c_{config_instance}_ic_{instance}_{oe_group1}', - css_class='col' - ), - Div( - f'c_{config_instance}_ic_{instance}_{oe_group2}', - css_class='col' - ), - css_class='form-row' - ) - ) - if len(oe_groups) % 2 == 1: - # We have one excess oe_group, so add it here - oe_groups_layout.append( - Div( - Div( - f'c_{config_instance}_ic_{instance}_{oe_groups[-1]}', - css_class='col' - ), - css_class='form-row' - ) - ) - return oe_groups_layout - - def _get_ic_layout(self, config_instance, instance, oe_groups): - return ( - Div( - Div( - f'c_{config_instance}_ic_{instance}_exposure_time', - css_class='col' - ), - Div( - f'c_{config_instance}_ic_{instance}_exposure_count', - css_class='col' - ), - css_class='form-row' - ), - *self._get_oe_groups_layout(config_instance, instance, oe_groups) - ) - - -class MuscatInstrumentConfigLayout(InstrumentConfigLayout): - def __init__(self, form_name, config_instance, oe_groups, *args, **kwargs): - super().__init__(form_name, config_instance, oe_groups, *args, **kwargs) - +class MuscatInstrumentConfigLayout(OCSInstrumentConfigLayout): def _get_ic_layout(self, config_instance, instance, oe_groups): return ( Div( @@ -661,10 +255,7 @@ def _get_ic_layout(self, config_instance, instance, oe_groups): ) -class SpectralInstrumentConfigLayout(InstrumentConfigLayout): - def __init__(self, form_name, config_instance, oe_groups, *args, **kwargs): - super().__init__(form_name, config_instance, oe_groups, *args, **kwargs) - +class SpectralInstrumentConfigLayout(OCSInstrumentConfigLayout): def _get_ic_layout(self, config_instance, instance, oe_groups): return ( Div( @@ -693,244 +284,13 @@ def _get_ic_layout(self, config_instance, instance, oe_groups): ) -class LCOBaseObservationForm(BaseRoboticObservationForm, LCOBaseForm): - """ - The LCOBaseObservationForm provides the base set of utilities to construct an observation at Las Cumbres - Observatory. It must be subclassed to be used, as some methods are not implemented in this class. - """ - name = forms.CharField() - ipp_value = forms.FloatField(label='Intra Proposal Priority (IPP factor)', - min_value=0.5, - max_value=2, - initial=1.05, - help_text=ipp_value_help) - start = forms.CharField(widget=forms.TextInput(attrs={'type': 'date'})) - end = forms.CharField(widget=forms.TextInput(attrs={'type': 'date'}), - help_text=end_help) - observation_mode = forms.ChoiceField( - choices=(('NORMAL', 'Normal'), ('RAPID_RESPONSE', 'Rapid-Response'), ('TIME_CRITICAL', 'Time-Critical')), - help_text=observation_mode_help - ) - optimization_type = forms.ChoiceField( - choices=(('TIME', 'Time'), ('AIRMASS', 'Airmass')), - required=False, - help_text=optimization_type_help - ) - configuration_repeats = forms.IntegerField( - min_value=1, - initial=1, - required=False, - label='Configuration Repeats', - help_text='Number of times to repeat the set of configurations, usually used for nodding between 2+ targets' - ) - period = forms.FloatField(help_text='Decimal Hours', required=False, min_value=0.0) - jitter = forms.FloatField(help_text='Decimal Hours', required=False, min_value=0.0) - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.fields['proposal'] = forms.ChoiceField(choices=self.proposal_choices()) - self.helper.layout = Layout( - self.common_layout, - self.layout(), - self.button_layout() - ) - - def button_layout(self): - target_id = self.initial.get('target_id') - return ButtonHolder( - Submit('submit', 'Submit'), - Submit('validateButton', 'Validate'), - HTML(f''' - Back''') - ) - - def clean_start(self): - start = self.cleaned_data['start'] - return parse(start).isoformat() - - def clean_end(self): - end = self.cleaned_data['end'] - return parse(end).isoformat() - - def validate_at_facility(self): - obs_module = get_service_class(self.cleaned_data['facility']) - response = obs_module().validate_observation(self.observation_payload()) - if response.get('request_durations', {}).get('duration'): - duration = response['request_durations']['duration'] - self.validation_message = f"This observation is valid with a duration of {duration} seconds." - if response.get('errors'): - self.add_error(None, self._flatten_error_dict(response['errors'])) - - def is_valid(self): - super().is_valid() - self.validate_at_facility() - if self._errors: - logger.warn(f'Facility submission has errors {self._errors}') - return not self._errors - - def _flatten_error_dict(self, error_dict): - non_field_errors = [] - for k, v in error_dict.items(): - if isinstance(v, list): - for i in v: - if isinstance(i, str): - if k in self.fields: - self.add_error(k, i) - else: - non_field_errors.append('{}: {}'.format(k, i)) - if isinstance(i, dict): - non_field_errors.append(self._flatten_error_dict(i)) - elif isinstance(v, str): - if k in self.fields: - self.add_error(k, v) - else: - non_field_errors.append('{}: {}'.format(k, v)) - elif isinstance(v, dict): - non_field_errors.append(self._flatten_error_dict(v)) - - return non_field_errors - - def _build_target_extra_params(self, configuration_id=1): - # if a fractional_ephemeris_rate has been specified, add it as an extra_param - # to the target_fields - if 'fractional_ephemeris_rate' in self.cleaned_data: - return {'fractional_ephemeris_rate': self.cleaned_data['fractional_ephemeris_rate']} - return {} - - def _build_target_fields(self, target_id, configuration_id=1): - target = Target.objects.get(pk=target_id) - target_fields = { - 'name': target.name, - } - if target.type == Target.SIDEREAL: - target_fields['type'] = 'ICRS' - target_fields['ra'] = target.ra - target_fields['dec'] = target.dec - target_fields['proper_motion_ra'] = target.pm_ra - target_fields['proper_motion_dec'] = target.pm_dec - target_fields['epoch'] = target.epoch - elif target.type == Target.NON_SIDEREAL: - target_fields['type'] = 'ORBITAL_ELEMENTS' - # Mapping from TOM field names to LCO API field names, for fields - # where there are differences - field_mapping = { - 'inclination': 'orbinc', - 'lng_asc_node': 'longascnode', - 'arg_of_perihelion': 'argofperih', - 'semimajor_axis': 'meandist', - 'mean_anomaly': 'meananom', - 'mean_daily_motion': 'dailymot', - 'epoch_of_elements': 'epochofel', - 'epoch_of_perihelion': 'epochofperih', - } - # The fields to include in the payload depend on the scheme. Add - # only those that are required - fields = (REQUIRED_NON_SIDEREAL_FIELDS - + REQUIRED_NON_SIDEREAL_FIELDS_PER_SCHEME[target.scheme]) - for field in fields: - lco_field = field_mapping.get(field, field) - target_fields[lco_field] = getattr(target, field) - - # - # Handle extra_params - # - if 'extra_params' not in target_fields: - target_fields['extra_params'] = {} - target_fields['extra_params'].update(self._build_target_extra_params(configuration_id)) - - return target_fields - - def _build_acquisition_config(self, configuration_id=1): - acquisition_config = {} - - return acquisition_config - - def _build_guiding_config(self, configuration_id=1): - guiding_config = {} - - return guiding_config - - def _build_instrument_config(self): - return [] - - def _build_configuration(self): - configuration = { - 'type': self.instrument_to_type(self.cleaned_data['instrument_type']), - 'instrument_type': self.cleaned_data['instrument_type'], - 'target': self._build_target_fields(self.cleaned_data['target_id']), - 'instrument_configs': self._build_instrument_config(), - 'acquisition_config': self._build_acquisition_config(), - 'guiding_config': self._build_guiding_config(), - 'constraints': { - 'max_airmass': self.cleaned_data['max_airmass'], - } - } - - if 'min_lunar_distance' in self.cleaned_data and self.cleaned_data.get('min_lunar_distance') is not None: - configuration['constraints']['min_lunar_distance'] = self.cleaned_data['min_lunar_distance'] - - return configuration - - def _build_location(self): - return {'telescope_class': self._get_instruments()[self.cleaned_data['instrument_type']]['class']} - - def _expand_cadence_request(self, payload): - payload['requests'][0]['cadence'] = { - 'start': self.cleaned_data['start'], - 'end': self.cleaned_data['end'], - 'period': self.cleaned_data['period'], - 'jitter': self.cleaned_data['jitter'] - } - payload['requests'][0]['windows'] = [] - - # use the LCO Observation Portal candence builder to build the candence - response = make_request( - 'POST', - PORTAL_URL + '/api/requestgroups/cadence/', - json=payload, - headers={'Authorization': 'Token {0}'.format(LCO_SETTINGS['api_key'])} - ) - return response.json() - - def observation_payload(self): - payload = { - 'name': self.cleaned_data['name'], - 'proposal': self.cleaned_data['proposal'], - 'ipp_value': self.cleaned_data['ipp_value'], - 'operator': 'SINGLE', - 'observation_type': self.cleaned_data['observation_mode'], - 'requests': [ - { - 'configurations': [self._build_configuration()], - 'windows': [ - { - 'start': self.cleaned_data['start'], - 'end': self.cleaned_data['end'] - } - ], - 'location': self._build_location() - } - ] - } - if self.cleaned_data.get('period') and self.cleaned_data.get('jitter') is not None: - payload = self._expand_cadence_request(payload) - - return payload - - -class LCOOldStyleObservationForm(LCOBaseObservationForm): +class LCOOldStyleObservationForm(OCSBaseObservationForm): """ The LCOOldStyleObservationForm provides the backwards compatibility for the Imaging and Spectral Sequence forms to remain the same as they were previously despite the upgrades to the other LCO forms. """ exposure_count = forms.IntegerField(min_value=1) - exposure_time = forms.FloatField(min_value=0.1, - widget=forms.TextInput(attrs={'placeholder': 'Seconds'}), - help_text=exposure_time_help) - max_airmass = forms.FloatField(help_text=max_airmass_help, min_value=0) min_lunar_distance = forms.IntegerField(min_value=0, label='Minimum Lunar Distance', required=False) - max_lunar_phase = forms.FloatField(help_text=max_lunar_phase_help, min_value=0, - max_value=1.0, label='Maximum Lunar Phase', required=False) fractional_ephemeris_rate = forms.FloatField(min_value=0.0, max_value=1.0, label='Fractional Ephemeris Rate', help_text='Value between 0 (Sidereal Tracking) ' @@ -938,8 +298,19 @@ class LCOOldStyleObservationForm(LCOBaseObservationForm): required=False) def __init__(self, *args, **kwargs): + if 'facility_settings' not in kwargs: + kwargs['facility_settings'] = LCOSettings("LCO") super().__init__(*args, **kwargs) - self.fields['filter'] = forms.ChoiceField(choices=self.filter_choices()) + self.fields['exposure_time'] = forms.FloatField( + min_value=0.1, widget=forms.TextInput(attrs={'placeholder': 'Seconds'}), + help_text=self.facility_settings.exposure_time_help + ) + self.fields['max_airmass'] = forms.FloatField(help_text=self.facility_settings.max_airmass_help, min_value=0) + self.fields['max_lunar_phase'] = forms.FloatField( + help_text=self.facility_settings.max_lunar_phase_help, min_value=0, + max_value=1.0, label='Maximum Lunar Phase', required=False + ) + self.fields['filter'] = forms.ChoiceField(choices=self.all_optical_element_choices()) self.fields['instrument_type'] = forms.ChoiceField(choices=self.instrument_choices()) if isinstance(self, CadenceForm): @@ -1026,7 +397,27 @@ def layout(self): ) ) - def _build_instrument_config(self): + def get_instruments(self): + instruments = super().get_instruments() + return { + code: instrument for (code, instrument) in instruments.items() if ( + 'IMAGE' == instrument['type'] and 'MUSCAT' not in code and 'SOAR' not in code) + } + + def all_optical_element_choices(self, use_code_only=False): + return sorted(set([ + (f['code'], f['code'] if use_code_only else f['name']) for ins in self.get_instruments().values() for f in + ins['optical_elements'].get('filters', []) + ins['optical_elements'].get('slits', []) if f['schedulable'] + ]), key=lambda filter_tuple: filter_tuple[1]) + + def _build_target_extra_params(self, configuration_id=1): + # if a fractional_ephemeris_rate has been specified, add it as an extra_param + # to the target_fields + if 'fractional_ephemeris_rate' in self.cleaned_data: + return {'fractional_ephemeris_rate': self.cleaned_data['fractional_ephemeris_rate']} + return {} + + def _build_instrument_configs(self): instrument_config = { 'exposure_count': self.cleaned_data['exposure_count'], 'exposure_time': self.cleaned_data['exposure_time'], @@ -1038,126 +429,28 @@ def _build_instrument_config(self): return [instrument_config] -class LCOFullObservationForm(LCOBaseObservationForm): - """ - The LCOFullObservationForm has all capabilities to construct an observation at Las Cumbres - Observatory. While the forms that inherit from it provide a subset of instruments and filters, the - LCOFullObservationForm presents the user with all of the instrument and filter options that the facility has to - offer. - """ - dither_pattern = forms.ChoiceField( - choices=(('', 'None'), ('line', 'Line'), ('grid', 'Grid'), ('spiral', 'Spiral')), - required=False, - help_text='Expand your Instrument Configurations with a set of offsets from the target following a pattern.' - ) - dither_num_points = forms.IntegerField(min_value=2, label='Number of Points', - help_text='Number of Points in the pattern (Line and Spiral only).', - required=False) - dither_point_spacing = forms.FloatField( - label='Point Spacing', help_text='Vertical spacing between offsets.', required=False, min_value=0.0) - dither_line_spacing = forms.FloatField( - label='Line Spacing', help_text='Horizontal spacing between offsets (Grid only).', - required=False, min_value=0.0) - dither_orientation = forms.FloatField( - label='Orientation', - help_text='Angular rotation of the pattern in degrees, measured clockwise East of North (Line and Grid only).', - required=False, min_value=0.0) - dither_num_rows = forms.IntegerField( - min_value=1, label='Number of Rows', required=False, - help_text='Number of offsets in the pattern in the RA direction (Grid only).') - dither_num_columns = forms.IntegerField( - min_value=1, label='Number of Columns', required=False, - help_text='Number of offsets in the pattern in the declination direction (Grid only).') - dither_center = forms.ChoiceField( - choices=((True, 'True'), (False, 'False')), - label='Center', - required=False, - help_text='If True, pattern is centered on initial target. Otherwise pattern begins at initial target.' - ) - mosaic_pattern = forms.ChoiceField( - choices=(('', 'None'), ('line', 'Line'), ('grid', 'Grid')), - required=False, - help_text="""Expand your Configurations with a set of different targets following a mosaic pattern. - Only works with Sidereal targets. - """ - ) - mosaic_num_points = forms.IntegerField(min_value=2, label='Number of Points', - help_text='Number of Points in the pattern (Line only).', required=False) - mosaic_point_overlap = forms.FloatField( - label='Point Overlap Percent', - help_text='Percentage overlap of pointings in the pattern as a percent of declination in FOV.', - required=False, min_value=0.0, max_value=100.0) - mosaic_line_overlap = forms.FloatField( - label='Line Overlap Percent', - help_text='Percentage overlap of pointings in the pattern as a percent of RA in FOV (Grid only).', - required=False, min_value=0.0, max_value=100.0) - mosaic_orientation = forms.FloatField( - label='Orientation', - help_text='Angular rotation of the pattern in degrees, measured clockwise East of North.', - required=False, min_value=0.0) - mosaic_num_rows = forms.IntegerField( - min_value=1, label='Number of Rows', - help_text='Number of pointings in the pattern in the declination direction (Grid only).', required=False) - mosaic_num_columns = forms.IntegerField( - min_value=1, label='Number of Columns', - help_text='Number of pointings in the pattern in the RA direction (Grid only).', required=False) - mosaic_center = forms.ChoiceField( - choices=((True, 'True'), (False, 'False')), - label='Center', - required=False, - help_text='If True, pattern is centered on initial target. Otherwise pattern begins at initial target.' - ) - - def __init__(self, data=None, **kwargs): - # convert data argument field names to the proper fields. Data is assumed to be observation payload format - data = self.convert_old_observation_payload_to_fields(data) - super().__init__(data, **kwargs) - for j in range(MAX_CONFIGURATIONS): - self.fields[f'c_{j+1}_instrument_type'] = forms.ChoiceField( - choices=self.instrument_choices(), required=False, help_text=instrument_type_help, - label='Instrument Type') - self.fields[f'c_{j+1}_configuration_type'] = forms.ChoiceField( - choices=self.configuration_type_choices(), required=False, label='Configuration Type') - self.fields[f'c_{j+1}_repeat_duration'] = forms.FloatField( - help_text=repeat_duration_help, required=False, label='Repeat Duration', - widget=forms.TextInput(attrs={'placeholder': 'Seconds'})) - self.fields[f'c_{j+1}_max_airmass'] = forms.FloatField( - help_text=max_airmass_help, label='Max Airmass', min_value=0, initial=1.6, required=False) - self.fields[f'c_{j+1}_min_lunar_distance'] = forms.IntegerField( - min_value=0, label='Minimum Lunar Distance', required=False) - self.fields[f'c_{j+1}_max_lunar_phase'] = forms.FloatField( - help_text=max_lunar_phase_help, min_value=0, max_value=1.0, label='Maximum Lunar Phase', required=False) +class LCOFullObservationForm(OCSFullObservationForm): + def __init__(self, *args, **kwargs): + if 'facility_settings' not in kwargs: + kwargs['facility_settings'] = LCOSettings("LCO") + if 'data' in kwargs: + # convert data argument field names to the proper fields. Data is assumed to be observation payload format + kwargs['data'] = self.convert_old_observation_payload_to_fields(kwargs['data']) + super().__init__(*args, **kwargs) + for j in range(self.facility_settings.get_setting('max_configurations')): self.fields[f'c_{j+1}_fractional_ephemeris_rate'] = forms.FloatField( min_value=0.0, max_value=1.0, label='Fractional Ephemeris Rate', help_text='Value between 0 (Sidereal Tracking) ' 'and 1 (Target Tracking). If blank, Target Tracking.', required=False ) - self.fields[f'c_{j+1}_target_override'] = forms.ChoiceField( - choices=self.target_group_choices(), - required=False, - help_text='Set a different target for this configuration. Must be in the same target group.', - label='Substitute Target for this Configuration' - ) - for i in range(MAX_INSTRUMENT_CONFIGS): - self.fields[f'c_{j+1}_ic_{i+1}_exposure_count'] = forms.IntegerField( - min_value=1, label='Exposure Count', initial=1, required=False) - self.fields[f'c_{j+1}_ic_{i+1}_exposure_time'] = forms.FloatField( - min_value=0.1, label='Exposure Time', - widget=forms.TextInput(attrs={'placeholder': 'Seconds'}), - help_text=exposure_time_help, required=False) - for oe_group in self.get_optical_element_groups(): - oe_group_plural = oe_group + 's' - self.fields[f'c_{j+1}_ic_{i+1}_{oe_group}'] = forms.ChoiceField( - choices=self.filter_choices_for_group(oe_group_plural), required=False, - label=oe_group.replace('_', ' ').capitalize()) - self.helper.layout = Layout( - self.common_layout, - self.layout(), - self.button_layout() - ) - if isinstance(self, CadenceForm): - self.helper.layout.insert(2, self.cadence_layout()) + # self.helper.layout = Layout( + # self.common_layout, + # self.layout(), + # self.button_layout() + # ) + # if isinstance(self, CadenceForm): + # self.helper.layout.insert(2, self.cadence_layout()) def convert_old_observation_payload_to_fields(self, data): """ This is a backwards compatibility function to allow us to load old-format observation parameters @@ -1189,245 +482,8 @@ def convert_old_observation_payload_to_fields(self, data): del data['filter'] return data - def form_name(self): - return 'base' - - def instrument_config_layout_class(self): - return InstrumentConfigLayout - def configuration_layout_class(self): - return ConfigurationLayout - - def layout(self): - return Div( - Div( - Div( - 'name', - css_class='col' - ), - Div( - 'proposal', - css_class='col' - ), - css_class='form-row' - ), - Div( - Div( - 'observation_mode', - css_class='col' - ), - Div( - 'ipp_value', - css_class='col' - ), - css_class='form-row' - ), - Div( - Div( - 'optimization_type', - css_class='col' - ), - Div( - 'configuration_repeats', - css_class='col' - ), - css_class='form-row' - ), - Div( - Div( - 'start', - css_class='col' - ), - Div( - 'end', - css_class='col' - ), - css_class='form-row' - ), - self.configuration_layout_class()( - self.form_name(), self.instrument_config_layout_class(), self.get_optical_element_groups() - ), - AdvancedExpansionsLayout(self.form_name()) - ) - - def _build_target_extra_params(self, configuration_id=1): - # if a fractional_ephemeris_rate has been specified, add it as an extra_param - # to the target_fields - if f'c_{configuration_id}_fractional_ephemeris_rate' in self.cleaned_data: - return {'fractional_ephemeris_rate': self.cleaned_data[f'c_{configuration_id}_fractional_ephemeris_rate']} - return {} - - def _build_instrument_config(self, instrument_type, configuration_id, id): - # If the instrument config did not have an exposure time set, leave it out by returning None - if not self.cleaned_data.get(f'c_{configuration_id}_ic_{id}_exposure_time'): - return None - instrument_config = { - 'exposure_count': self.cleaned_data[f'c_{configuration_id}_ic_{id}_exposure_count'], - 'exposure_time': self.cleaned_data[f'c_{configuration_id}_ic_{id}_exposure_time'], - 'optical_elements': {} - } - for oe_group in self.get_optical_element_groups(): - instrument_config['optical_elements'][oe_group] = self.cleaned_data.get( - f'c_{configuration_id}_ic_{id}_{oe_group}') - - return instrument_config - - def _build_instrument_configs(self, instrument_type, configuration_id): - ics = [] - for i in range(MAX_INSTRUMENT_CONFIGS): - ic = self._build_instrument_config(instrument_type, configuration_id, i+1) - # This will only include instrument configs with an exposure time set - if ic: - ics.append(ic) - - return ics - - def _build_configuration(self, id): - instrument_configs = self._build_instrument_configs(self.cleaned_data[f'c_{id}_instrument_type'], id) - # Check if the instrument configs are empty, and if so, leave this configuration out by returning None - if not instrument_configs: - return None - configuration = { - 'type': self.cleaned_data[f'c_{id}_configuration_type'], - 'instrument_type': self.cleaned_data[f'c_{id}_instrument_type'], - 'instrument_configs': instrument_configs, - 'acquisition_config': self._build_acquisition_config(id), - 'guiding_config': self._build_guiding_config(id), - 'constraints': { - 'max_airmass': self.cleaned_data[f'c_{id}_max_airmass'], - } - } - if self.cleaned_data.get(f'c_{id}_target_override'): - configuration['target'] = self._build_target_fields(self.cleaned_data[f'c_{id}_target_override']) - else: - configuration['target'] = self._build_target_fields(self.cleaned_data['target_id']) - if self.cleaned_data.get(f'c_{id}_repeat_duration'): - configuration['repeat_duration'] = self.cleaned_data[f'c_{id}_repeat_duration'] - if self.cleaned_data.get(f'c_{id}_min_lunar_distance'): - configuration['constraints']['min_lunar_distance'] = self.cleaned_data[f'c_{id}_min_lunar_distance'] - if self.cleaned_data.get(f'c_{id}_max_lunar_phase'): - configuration['constraints']['max_lunar_phase'] = self.cleaned_data[f'c_{id}_max_lunar_phase'] - - return configuration - - def _build_configurations(self): - configurations = [] - for j in range(MAX_CONFIGURATIONS): - configuration = self._build_configuration(j+1) - if configuration: - configurations.append(configuration) - - return configurations - - def _expand_dither_pattern(self, configuration): - payload = { - 'configuration': configuration, - 'pattern': self.cleaned_data.get('dither_pattern'), - 'center': self.cleaned_data.get('dither_center') - } - if self.cleaned_data.get('dither_orientation'): - payload['orientation'] = self.cleaned_data['dither_orientation'] - if self.cleaned_data.get('dither_point_spacing'): - payload['point_spacing'] = self.cleaned_data['dither_point_spacing'] - if payload['pattern'] in ['line', 'spiral'] and self.cleaned_data.get('dither_num_points'): - payload['num_points'] = self.cleaned_data['dither_num_points'] - if payload['pattern'] == 'grid': - if self.cleaned_data.get('dither_num_rows'): - payload['num_rows'] = self.cleaned_data['dither_num_rows'] - if self.cleaned_data.get('dither_num_columns'): - payload['num_columns'] = self.cleaned_data['dither_num_columns'] - if self.cleaned_data.get('dither_line_spacing'): - payload['line_spacing'] = self.cleaned_data['dither_line_spacing'] - # Use the LCO Observation Portal dither pattern expansion to expand the configuration - response_json = {} - try: - response = make_request( - 'POST', - PORTAL_URL + '/api/configurations/dither/', - json=payload, - headers={'Authorization': 'Token {0}'.format(LCO_SETTINGS['api_key'])} - ) - response_json = response.json() - response.raise_for_status() - return response_json - except Exception: - logger.warning(f"Error expanding dither pattern: {response_json}") - return configuration - - def _expand_mosaic_pattern(self, request): - payload = { - 'request': request, - 'pattern': self.cleaned_data.get('mosaic_pattern'), - 'center': self.cleaned_data.get('mosaic_center') - } - if self.cleaned_data.get('mosaic_orientation'): - payload['orientation'] = self.cleaned_data['mosaic_orientation'] - if self.cleaned_data.get('mosaic_point_overlap'): - payload['point_overlap_percent'] = self.cleaned_data['mosaic_point_overlap'] - if payload['pattern'] == 'line' and self.cleaned_data.get('mosaic_num_points'): - payload['num_points'] = self.cleaned_data['mosaic_num_points'] - if payload['pattern'] == 'grid': - if self.cleaned_data.get('mosaic_num_rows'): - payload['num_rows'] = self.cleaned_data['mosaic_num_rows'] - if self.cleaned_data.get('mosaic_num_columns'): - payload['num_columns'] = self.cleaned_data['mosaic_num_columns'] - if self.cleaned_data.get('mosaic_line_overlap'): - payload['line_overlap_percent'] = self.cleaned_data['mosaic_line_overlap'] - - # Use the LCO Observation Portal dither pattern expansion to expand the configuration - response_json = {} - try: - response = make_request( - 'POST', - PORTAL_URL + '/api/requests/mosaic/', - json=payload, - headers={'Authorization': 'Token {0}'.format(LCO_SETTINGS['api_key'])} - ) - response_json = response.json() - response.raise_for_status() - return response_json - except Exception: - logger.warning(f"Error expanding mosaic pattern: {response_json}") - return request - - def _build_location(self, configuration_id=1): - return { - 'telescope_class': self._get_instruments()[ - self.cleaned_data[f'c_{configuration_id}_instrument_type']]['class'] - } - - def observation_payload(self): - payload = { - 'name': self.cleaned_data['name'], - 'proposal': self.cleaned_data['proposal'], - 'ipp_value': self.cleaned_data['ipp_value'], - 'operator': 'SINGLE', - 'observation_type': self.cleaned_data['observation_mode'], - 'requests': [ - { - 'optimization_type': self.cleaned_data['optimization_type'], - 'configuration_repeats': self.cleaned_data['configuration_repeats'], - 'configurations': self._build_configurations(), - 'windows': [ - { - 'start': self.cleaned_data['start'], - 'end': self.cleaned_data['end'] - } - ], - 'location': self._build_location() - } - ] - } - if (self.cleaned_data.get('dither_pattern') and self.cleaned_data.get('dither_point_spacing') and len( - payload['requests'][0]['configurations']) == 1): - payload['requests'][0]['configurations'][0] = self._expand_dither_pattern( - payload['requests'][0]['configurations'][0]) - if self.cleaned_data.get('mosaic_pattern') and len(payload['requests'][0]['configurations']) == 1: - payload['requests'][0] = self._expand_mosaic_pattern(payload['requests'][0]) - if self.cleaned_data.get('period') and self.cleaned_data.get('jitter') is not None: - payload = self._expand_cadence_request(payload) - - return payload + return LCOConfigurationLayout class LCOImagingObservationForm(LCOFullObservationForm): @@ -1457,12 +513,14 @@ class LCOMuscatImagingObservationForm(LCOFullObservationForm): """ def __init__(self, *args, **kwargs): + if 'facility_settings' not in kwargs: + kwargs['facility_settings'] = LCOSettings("LCO") super().__init__(*args, **kwargs) # Need to add the muscat specific exposure time fields to this form - for j in range(MAX_CONFIGURATIONS): + for j in range(self.facility_settings.get_setting('max_configurations')): self.fields[f'c_{j+1}_guide_mode'] = forms.ChoiceField( choices=self.mode_choices('guiding'), required=False, label='Guide Mode') - for i in range(MAX_INSTRUMENT_CONFIGS): + for i in range(self.facility_settings.get_setting('max_instrument_configs')): self.fields.pop(f'c_{j+1}_ic_{i+1}_exposure_time', None) self.fields[f'c_{j+1}_ic_{i+1}_exposure_time_g'] = forms.FloatField( min_value=0.0, label='Exposure Time g', @@ -1481,7 +539,7 @@ def __init__(self, *args, **kwargs): self.fields[f'c_{j+1}_ic_{i+1}_exposure_mode'] = forms.ChoiceField( label='Exposure Mode', required=False, choices=self.mode_choices('exposure'), - help_text=muscat_exposure_mode_help + help_text=self.facility_settings.muscat_exposure_mode_help ) def convert_old_observation_payload_to_fields(self, data): @@ -1567,11 +625,13 @@ class LCOSpectroscopyObservationForm(LCOFullObservationForm): """ def __init__(self, *args, **kwargs): + if 'facility_settings' not in kwargs: + kwargs['facility_settings'] = LCOSettings("LCO") super().__init__(*args, **kwargs) - for j in range(MAX_CONFIGURATIONS): + for j in range(self.facility_settings.get_setting('max_configurations')): self.fields[f'c_{j+1}_acquisition_mode'] = forms.ChoiceField( choices=self.mode_choices('acquisition', use_code_only=True), required=False, label='Acquisition Mode') - for i in range(MAX_INSTRUMENT_CONFIGS): + for i in range(self.facility_settings.get_setting('max_instrument_configs')): self.fields[f'c_{j+1}_ic_{i+1}_rotator_mode'] = forms.ChoiceField( choices=self.mode_choices('rotator'), label='Rotator Mode', required=False, help_text='Only for Floyds') @@ -1664,10 +724,12 @@ class LCOPhotometricSequenceForm(LCOOldStyleObservationForm): cadence_frequency = forms.IntegerField(required=True, help_text='in hours') def __init__(self, *args, **kwargs): + if 'facility_settings' not in kwargs: + kwargs['facility_settings'] = LCOSettings("LCO") super().__init__(*args, **kwargs) # Add fields for each available filter as specified in the filters property - for filter_code, filter_name in LCOPhotometricSequenceForm.filter_choices(): + for filter_code, filter_name in self.all_optical_element_choices(): self.fields[filter_code] = FilterField(label=filter_name, required=False) # Massage cadence form to be SNEx-styled @@ -1694,16 +756,16 @@ def __init__(self, *args, **kwargs): self.button_layout() ) - def _build_instrument_config(self): + def _build_instrument_configs(self): """ Because the photometric sequence form provides form inputs for 10 different filters, they must be constructed into a list of instrument configurations as per the LCO API. This method constructs the instrument configurations in the appropriate manner. """ - instrument_config = [] + instrument_configs = [] for filter_name in self.valid_filters: if len(self.cleaned_data[filter_name]) > 0: - instrument_config.append({ + instrument_configs.append({ 'exposure_count': self.cleaned_data[filter_name][1], 'exposure_time': self.cleaned_data[filter_name][0], 'optical_elements': { @@ -1711,7 +773,7 @@ def _build_instrument_config(self): } }) - return instrument_config + return instrument_configs def clean_start(self): """ @@ -1740,20 +802,18 @@ def clean(self): return cleaned_data - @staticmethod - def instrument_choices(): + def instrument_choices(self): """ This method returns only the instrument choices available in the current SNEx photometric sequence form. """ return sorted([(k, v['name']) - for k, v in LCOPhotometricSequenceForm._get_instruments().items() + for k, v in self._get_instruments().items() if k in LCOPhotometricSequenceForm.valid_instruments], key=lambda inst: inst[1]) - @staticmethod - def filter_choices(): + def all_optical_element_choices(self, use_code_only=False): return sorted(set([ - (f['code'], f['name']) for ins in LCOPhotometricSequenceForm._get_instruments().values() for f in + (f['code'], f['name']) for ins in self._get_instruments().values() for f in ins['optical_elements'].get('filters', []) if f['code'] in LCOPhotometricSequenceForm.valid_filters]), key=lambda filter_tuple: filter_tuple[1]) @@ -1811,6 +871,8 @@ class LCOSpectroscopicSequenceForm(LCOOldStyleObservationForm): widget=forms.NumberInput(attrs={'placeholder': 'Hours'})) def __init__(self, *args, **kwargs): + if 'facility_settings' not in kwargs: + kwargs['facility_settings'] = LCOSettings("LCO") super().__init__(*args, **kwargs) # Massage cadence form to be SNEx-styled @@ -1845,8 +907,8 @@ def __init__(self, *args, **kwargs): self.button_layout() ) - def _build_instrument_config(self): - instrument_configs = super()._build_instrument_config() + def _build_instrument_configs(self): + instrument_configs = super()._build_instrument_configs() instrument_configs[0]['optical_elements'].pop('filter') instrument_configs[0]['optical_elements']['slit'] = self.cleaned_data['filter'] @@ -1911,19 +973,16 @@ def clean(self): return cleaned_data - @staticmethod - def instrument_choices(): + def instrument_choices(self): # SNEx only uses the Spectroscopic Sequence Form with FLOYDS # This doesn't need to be sorted because it will only return one instrument return [(k, v['name']) - for k, v in LCOSpectroscopicSequenceForm._get_instruments().items() + for k, v in self._get_instruments().items() if k == '2M0-FLOYDS-SCICAM'] - @staticmethod - def filter_choices(): - # SNEx only uses the Spectroscopic Sequence Form with FLOYDS + def all_optical_element_choices(self, use_code_only=False): return sorted(set([ - (f['code'], f['name']) for name, ins in LCOSpectroscopicSequenceForm._get_instruments().items() for f in + (f['code'], f['name']) for name, ins in self._get_instruments().items() for f in ins['optical_elements'].get('slits', []) if name == '2M0-FLOYDS-SCICAM' ]), key=lambda filter_tuple: filter_tuple[1]) @@ -1949,45 +1008,12 @@ def layout(self): ) -class LCOObservationTemplateForm(GenericTemplateForm, LCOTemplateBaseForm): - """ - The template form modifies the LCOTemplateBaseForm in order to only provide fields - that make sense to stay the same for the template. For example, there is no - point to making start_time an available field, as it will change between - observations. - """ - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - for field_name in ['groups', 'target_id']: - self.fields.pop(field_name, None) - for field in self.fields: - if field != 'template_name': - self.fields[field].required = False - self.helper.layout = Layout( - self.common_layout, - Div( - Div( - 'proposal', 'ipp_value', 'filter', 'instrument_type', - css_class='col' - ), - Div( - 'exposure_count', 'exposure_time', 'max_airmass', - css_class='col' - ), - css_class='form-row', - ) - ) - - -class LCOFacility(BaseRoboticObservationFacility): +class LCOFacility(OCSFacility): """ The ``LCOFacility`` is the interface to the Las Cumbres Observatory Observation Portal. For information regarding LCO observing and the available parameters, please see https://observe.lco.global/help/. """ - name = 'LCO' - # TODO: make the keys the display values instead observation_forms = { 'IMAGING': LCOImagingObservationForm, 'MUSCAT_IMAGING': LCOMuscatImagingObservationForm, @@ -1995,49 +1021,9 @@ class LCOFacility(BaseRoboticObservationFacility): 'PHOTOMETRIC_SEQUENCE': LCOPhotometricSequenceForm, 'SPECTROSCOPIC_SEQUENCE': LCOSpectroscopicSequenceForm } - # The SITES dictionary is used to calculate visibility intervals in the - # planning tool. All entries should contain latitude, longitude, elevation - # and a code. - # TODO: Flip sitecode and site name - # TODO: Why is tlv not represented here? - SITES = { - 'Siding Spring': { - 'sitecode': 'coj', - 'latitude': -31.272, - 'longitude': 149.07, - 'elevation': 1116 - }, - 'Sutherland': { - 'sitecode': 'cpt', - 'latitude': -32.38, - 'longitude': 20.81, - 'elevation': 1804 - }, - 'Teide': { - 'sitecode': 'tfn', - 'latitude': 20.3, - 'longitude': -16.511, - 'elevation': 2390 - }, - 'Cerro Tololo': { - 'sitecode': 'lsc', - 'latitude': -30.167, - 'longitude': -70.804, - 'elevation': 2198 - }, - 'McDonald': { - 'sitecode': 'elp', - 'latitude': 30.679, - 'longitude': -104.015, - 'elevation': 2027 - }, - 'Haleakala': { - 'sitecode': 'ogg', - 'latitude': 20.706, - 'longitude': -156.258, - 'elevation': 3065 - } - } + + def __init__(self, facility_settings=LCOSettings('LCO')): + super().__init__(facility_settings=facility_settings) # TODO: this should be called get_form_class def get_form(self, observation_type): @@ -2045,282 +1031,4 @@ def get_form(self, observation_type): # TODO: this should be called get_template_form_class def get_template_form(self, observation_type): - return LCOObservationTemplateForm - - def submit_observation(self, observation_payload): - response = make_request( - 'POST', - PORTAL_URL + '/api/requestgroups/', - json=observation_payload, - headers=self._portal_headers() - ) - return [r['id'] for r in response.json()['requests']] - - def validate_observation(self, observation_payload): - response = make_request( - 'POST', - PORTAL_URL + '/api/requestgroups/validate/', - json=observation_payload, - headers=self._portal_headers() - ) - return response.json() - - def cancel_observation(self, observation_id): - requestgroup_id = self._get_requestgroup_id(observation_id) - - response = make_request( - 'POST', - f'{PORTAL_URL}/api/requestgroups/{requestgroup_id}/cancel/', - headers=self._portal_headers() - ) - - return response.json()['state'] == 'CANCELED' - - def get_observation_url(self, observation_id): - return PORTAL_URL + '/requests/' + observation_id - - def get_flux_constant(self): - return FLUX_CONSTANT - - def get_wavelength_units(self): - return WAVELENGTH_UNITS - - def get_date_obs_from_fits_header(self, header): - return header.get(FITS_FACILITY_DATE_OBS_KEYWORD, None) - - def is_fits_facility(self, header): - """ - Returns True if the 'ORIGIN' keyword is in the given FITS header and contains the value 'LCOGT', False - otherwise. - - :param header: FITS header object - :type header: dictionary-like - - :returns: True if header matches LCOGT, False otherwise - :rtype: boolean - """ - return FITS_FACILITY_KEYWORD_VALUE == header.get(FITS_FACILITY_KEYWORD, None) - - def get_start_end_keywords(self): - return ('start', 'end') - - def get_terminal_observing_states(self): - return TERMINAL_OBSERVING_STATES - - def get_failed_observing_states(self): - return FAILED_OBSERVING_STATES - - def get_observing_sites(self): - return self.SITES - - def get_facility_weather_urls(self): - """ - `facility_weather_urls = {'code': 'XYZ', 'sites': [ site_dict, ... ]}` - where - `site_dict = {'code': 'XYZ', 'weather_url': 'http://path/to/weather'}` - """ - # TODO: manually add a weather url for tlv - facility_weather_urls = { - 'code': 'LCO', - 'sites': [ - { - 'code': site['sitecode'], - 'weather_url': f'https://weather.lco.global/#/{site["sitecode"]}' - } - for site in self.SITES.values()] - } - - return facility_weather_urls - - def get_facility_status(self): - """ - Get the telescope_states from the LCO API endpoint and simply - transform the returned JSON into the following dictionary hierarchy - for use by the facility_status.html template partial. - - facility_dict = {'code': 'LCO', 'sites': [ site_dict, ... ]} - site_dict = {'code': 'XYZ', 'telescopes': [ telescope_dict, ... ]} - telescope_dict = {'code': 'XYZ', 'status': 'AVAILABILITY'} - - Here's an example of the returned dictionary: - - literal_facility_status_example = { - 'code': 'LCO', - 'sites': [ - { - 'code': 'BPL', - 'telescopes': [ - { - 'code': 'bpl.doma.1m0a', - 'status': 'AVAILABLE' - }, - ], - }, - { - 'code': 'ELP', - 'telescopes': [ - { - 'code': 'elp.doma.1m0a', - 'status': 'AVAILABLE' - }, - { - 'code': 'elp.domb.1m0a', - 'status': 'AVAILABLE' - }, - ] - } - ] - } - - :return: facility_dict - """ - # make the request to the LCO API for the telescope_states - response = make_request( - 'GET', - PORTAL_URL + '/api/telescope_states/', - headers=self._portal_headers() - ) - telescope_states = response.json() - - # Now, transform the telescopes_state dictionary in a dictionary suitable - # for the facility_status.html template partial. - - # set up the return value to be populated by the for loop below - facility_status = { - 'code': self.name, - 'sites': [] - } - site_list = [site["sitecode"] for site in self.SITES.values()] - - for telescope_key, telescope_value in telescope_states.items(): - [site_code, _, _] = telescope_key.split('.') - - # limit returned sites to those provided by the facility - if site_code in site_list: - - # extract this telescope and it's status from the response - telescope = { - 'code': telescope_key, - 'status': telescope_value[0]['event_type'] - } - - # get the site dictionary from the facilities list of sites - # filter by site_code and provide a default (None) for new sites - site = next((site_ix for site_ix in facility_status['sites'] - if site_ix['code'] == site_code), None) - # create the site if it's new and not yet in the facility_status['sites] list - if site is None: - new_site = { - 'code': site_code, - 'telescopes': [] - } - facility_status['sites'].append(new_site) - site = new_site - - # Now, add the telescope to the site's telescopes - site['telescopes'].append(telescope) - - return facility_status - - def get_observation_status(self, observation_id): - response = make_request( - 'GET', - PORTAL_URL + '/api/requests/{0}'.format(observation_id), - headers=self._portal_headers() - ) - state = response.json()['state'] - - response = make_request( - 'GET', - PORTAL_URL + '/api/requests/{0}/observations/'.format(observation_id), - headers=self._portal_headers() - ) - blocks = response.json() - current_block = None - for block in blocks: - if block['state'] == 'COMPLETED': - current_block = block - break - elif block['state'] == 'PENDING': - current_block = block - if current_block: - scheduled_start = current_block['start'] - scheduled_end = current_block['end'] - else: - scheduled_start, scheduled_end = None, None - - return {'state': state, 'scheduled_start': scheduled_start, 'scheduled_end': scheduled_end} - - def data_products(self, observation_id, product_id=None): - products = [] - for frame in self._archive_frames(observation_id, product_id): - products.append({ - 'id': frame['id'], - 'filename': frame['filename'], - 'created': parse(frame['DATE_OBS']), - 'url': frame['url'] - }) - return products - - # The following methods are used internally by this module - # and should not be called directly from outside code. - - def _portal_headers(self): - if LCO_SETTINGS.get('api_key'): - return {'Authorization': 'Token {0}'.format(LCO_SETTINGS['api_key'])} - else: - return {} - - def _get_requestgroup_id(self, observation_id): - query_params = urlencode({'request_id': observation_id}) - - response = make_request( - 'GET', - f'{PORTAL_URL}/api/requestgroups?{query_params}', - headers=self._portal_headers() - ) - requestgroups = response.json() - - if requestgroups['count'] == 1: - return requestgroups['results'][0]['id'] - - def _archive_headers(self): - if LCO_SETTINGS.get('api_key'): - archive_token = cache.get('LCO_ARCHIVE_TOKEN') - if not archive_token: - response = make_request( - 'GET', - PORTAL_URL + '/api/profile/', - headers={'Authorization': 'Token {0}'.format(LCO_SETTINGS['api_key'])} - ) - archive_token = response.json().get('tokens', {}).get('archive') - if archive_token: - cache.set('LCO_ARCHIVE_TOKEN', archive_token, 3600) - return {'Authorization': 'Bearer {0}'.format(archive_token)} - - else: - return {'Authorization': 'Bearer {0}'.format(archive_token)} - else: - return {} - - def _archive_frames(self, observation_id, product_id=None): - # todo save this key somewhere - frames = [] - if product_id: - response = make_request( - 'GET', - 'https://archive-api.lco.global/frames/{0}/'.format(product_id), - headers=self._archive_headers() - ) - frames = [response.json()] - else: - url = 'https://archive-api.lco.global/frames/?REQNUM={0}&limit=1000'.format(observation_id) - while url: - response = make_request( - 'GET', - url, - headers=self._archive_headers() - ) - frames.extend(response.json()['results']) - url = response.json()['next'] - return frames + return LCOTemplateBaseForm diff --git a/tom_observations/facilities/ocs.py b/tom_observations/facilities/ocs.py new file mode 100644 index 000000000..6e5d8ad26 --- /dev/null +++ b/tom_observations/facilities/ocs.py @@ -0,0 +1,1455 @@ +from datetime import datetime +import logging +import requests +from urllib.parse import urlencode, urljoin + +from astropy import units as u +from crispy_forms.bootstrap import Accordion, AccordionGroup, TabHolder, Tab, Alert +from crispy_forms.layout import Div, HTML, Layout +from dateutil.parser import parse +from django import forms +from django.conf import settings +from django.core.cache import cache + +from tom_common.exceptions import ImproperCredentialsException +from tom_observations.cadence import CadenceForm +from tom_observations.facility import BaseRoboticObservationFacility, BaseRoboticObservationForm, get_service_class +from tom_observations.observation_template import GenericTemplateForm +from tom_targets.models import Target, REQUIRED_NON_SIDEREAL_FIELDS, REQUIRED_NON_SIDEREAL_FIELDS_PER_SCHEME + +logger = logging.getLogger(__name__) + + +class OCSSettings(): + """ Class encapsulates the settings from django for this Facility, and some of the options for + an OCS form implementation. The facility_name is used for retrieving the settings from the + FACILITIES dictionary in settings.py. + """ + # These class variables describe default help text for a variety of OCS fields. Override + # them as desired for a specific OCS implementation + ipp_value_help = """ + Value between 0.5 to 2.0. + + More information about Intra Proprosal Priority (IPP). + + """ + + observation_mode_help = """ + + More information about Rapid Response mode. + + """ + + optimization_type_help = """ + Scheduling optimization emphasis: Time for ASAP, or Airmass for minimum airmass. + """ + + end_help = "" + + instrument_type_help = "" + + exposure_time_help = "" + + max_lunar_phase_help = """ + Value between 0 (new moon) and 1 (full moon). + """ + + static_cadencing_help = """ + Static cadence parameters. Leave blank if no cadencing is desired. + """ + + repeat_duration_help = """ + The requested duration for this configuration to be repeated within. + Only applicable to * Sequence configuration types. + """ + + def __init__(self, facility_name): + self.facility_name = facility_name + + def get_setting(self, key): + default_settings = { + 'portal_url': '', + 'archive_url': '', + 'api_key': '', + 'max_instrument_configs': 5, + 'max_configurations': 5 + } + return settings.FACILITIES.get(self.facility_name, default_settings).get(key, default_settings[key]) + + def get_observing_states(self): + return [ + 'PENDING', 'COMPLETED', 'WINDOW_EXPIRED', 'CANCELED', 'FAILURE_LIMIT_REACHED', 'NOT_ATTEMPTED' + ] + + def get_pending_observing_states(self): + return ['PENDING'] + + def get_successful_observing_states(self): + return ['COMPLETED'] + + def get_failed_observing_states(self): + return ['WINDOW_EXPIRED', 'CANCELED', 'FAILURE_LIMIT_REACHED', 'NOT_ATTEMPTED'] + + def get_terminal_observing_states(self): + return self.get_successful_observing_states() + self.get_failed_observing_states() + + def get_fits_facility_header_keyword(self): + """ Should return the fits header keyword that stores what facility the data was taken at + """ + return 'ORIGIN' + + def get_fits_facility_header_value(self): + """ Should return the expected value in the fits facility header for data from this facility + """ + return 'OCS' + + def get_fits_header_dateobs_keyword(self): + """ Should return the fits header keyword that stores the date the data was taken at + """ + return 'DATE-OBS' + + def get_data_flux_constant(self): + return (1e-15 * u.erg) / (u.cm ** 2 * u.second * u.angstrom) + + def get_data_wavelength_units(self): + return u.angstrom + + def get_sites(self): + """ + Return an iterable of dictionaries that contain the information + necessary to be used in the planning (visibility) tool. + Format: + { + 'Site Name': { + 'sitecode': 'tst', + 'latitude': -31.272, + 'longitude': 149.07, + 'elevation': 1116 + }, + } + """ + return {} + + def get_weather_urls(self): + """ Return a dictionary containing urls to check the weather for each site in your sites dictionary + Format: + { + 'code': 'OCS', + 'sites': [ + { + 'code': sitecode, + 'weather_url': weather_url for site + } + ] + } + """ + return { + 'code': self.facility_name, + 'sites': [] + } + +def make_request(*args, **kwargs): + response = requests.request(*args, **kwargs) + if 401 <= response.status_code <= 403: + raise ImproperCredentialsException('OCS: ' + str(response.content)) + elif 400 == response.status_code: + raise forms.ValidationError(f'OCS: {str(response.content)}') + response.raise_for_status() + return response + + +class OCSBaseForm(forms.Form): + """ The OCSBaseForm assumes nothing of fields, and just adds helper methods for getting + data from an OCS portal to other form subclasses. + """ + def __init__(self, *args, **kwargs): + if 'facility_settings' not in kwargs: + kwargs['facility_settings'] = OCSSettings("OCS") + self.facility_settings = kwargs.pop('facility_settings') + super().__init__(*args, **kwargs) + + def target_group_choices(self): + target_id = self.data.get('target_id') + if not target_id: + target_id = self.initial.get('target_id') + try: + target_name = Target.objects.get(pk=target_id).name + group_targets = Target.objects.filter(targetlist__targets__pk=target_id).exclude( + pk=target_id).order_by('name').distinct().values_list('pk', 'name') + return [(target_id, target_name)] + list(group_targets) + except Target.DoesNotExist: + return [] + + def _get_instruments(self): + cached_instruments = cache.get(f'{self.facility_settings.facility_name}_instruments') + + if not cached_instruments: + logger.warning("Instruments not cached, getting them again!!!") + response = make_request( + 'GET', + urljoin(self.facility_settings.get_setting('portal_url'), '/api/instruments/'), + headers={'Authorization': 'Token {0}'.format(self.facility_settings.get_setting('api_key'))} + ) + cached_instruments = {k: v for k, v in response.json().items()} + cache.set(f'{self.facility_settings.facility_name}_instruments', cached_instruments, 3600) + return cached_instruments + + def get_instruments(self): + return self._get_instruments() + + def instrument_choices(self): + return sorted([(k, v.get('name')) for k, v in self.get_instruments().items()], key=lambda inst: inst[1]) + + def mode_choices(self, mode_type, use_code_only=False): + return sorted(set([ + (f['code'], f['code'] if use_code_only else f['name']) for ins in self.get_instruments().values() for f in + ins.get('modes', {}).get(mode_type, {}).get('modes', []) + ]), key=lambda filter_tuple: filter_tuple[1]) + + def filter_choices_for_group(self, oe_group, use_code_only=False): + return sorted(set([ + (f['code'], f['code'] if use_code_only else f['name']) for ins in self.get_instruments().values() for f in + ins['optical_elements'].get(oe_group, []) if f.get('schedulable') + ]), key=lambda filter_tuple: filter_tuple[1]) + + def instrument_to_default_configuration_type(self, instrument_type): + return self.get_instruments().get(instrument_type, {}).get('default_configuration_type', '') + + def all_optical_element_choices(self, use_code_only=False): + optical_elements = set() + for ins in self.get_instruments().values(): + for oe_group in ins.get('optical_elements', {}).values(): + for element in oe_group: + if element.get('schedulable'): + optical_elements.add((element['code'], element['code'] if use_code_only else element['name'])) + return sorted(optical_elements, key=lambda x: x[1]) + + def get_optical_element_groups(self): + oe_groups = set() + for instrument in self.get_instruments().values(): + for oe_group in instrument['optical_elements'].keys(): + oe_groups.add(oe_group.rstrip('s')) + return sorted(oe_groups) + + def configuration_type_choices(self): + all_config_types = set() + for instrument in self.get_instruments().values(): + config_types = instrument.get('configuration_types', {}).values() + all_config_types.update( + {(config_type.get('code'), config_type.get('name')) + for config_type in config_types if config_type.get('schedulable')} + ) + return sorted(all_config_types, key=lambda config_type: config_type[1]) + + def proposal_choices(self): + now = datetime.now() + response = make_request( + 'GET', + urljoin(self.facility_settings.get_setting('portal_url'), '/api/profile/'), + headers={'Authorization': 'Token {0}'.format(self.facility_settings.get_setting('api_key'))} + ) + choices = [] + for p in response.json()['proposals']: + if p['current']: + choices.append((p['id'], '{} ({})'.format(p['title'], p['id']))) + logger.warning(f"Get proposals took {(datetime.now() - now).total_seconds()}") + return choices + + +class OCSTemplateBaseForm(GenericTemplateForm, OCSBaseForm): + ipp_value = forms.FloatField() + exposure_count = forms.IntegerField(min_value=1) + exposure_time = forms.FloatField(min_value=0.1) + max_airmass = forms.FloatField() + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.fields['proposal'] = forms.ChoiceField(choices=self.proposal_choices()) + self.fields['filter'] = forms.ChoiceField(choices=self.all_optical_element_choices()) + self.fields['instrument_type'] = forms.ChoiceField(choices=self.instrument_choices()) + for field_name in ['groups', 'target_id']: + self.fields.pop(field_name, None) + for field in self.fields: + if field != 'template_name': + self.fields[field].required = False + self.helper.layout = Layout( + self.common_layout, + Div( + Div( + 'proposal', 'ipp_value', 'filter', 'instrument_type', + css_class='col' + ), + Div( + 'exposure_count', 'exposure_time', 'max_airmass', + css_class='col' + ), + css_class='form-row', + ) + ) + + +class OCSAdvancedExpansionsLayout(Layout): + def __init__(self, form_name, facility_settings, *args, **kwargs): + self.facility_settings = facility_settings + super().__init__( + Accordion( + *self._get_accordion_group(form_name) + ) + ) + + def _get_accordion_group(self, form_name): + return ( + AccordionGroup( + 'Cadence / Dither / Mosaic', + Alert( + content="""Using the following sections each result in expanding portions of the Request + on submission. You should only combine these if you know what you are doing. + """, + css_class='alert-warning' + ), + TabHolder( + Tab('Cadence', + Div( + HTML(f'''

{self.facility_settings.static_cadencing_help}

'''), + ), + Div( + Div( + 'period', + css_class='col' + ), + Div( + 'jitter', + css_class='col' + ), + css_class='form-row' + ), + css_id=f'{form_name}_cadence' + ), + Tab('Dithering', + Alert( + content="Dithering will only be applied if you have a single Configuration specified.", + css_class='alert-warning' + ), + Div( + Div( + 'dither_pattern', + css_class='col' + ), + Div( + 'dither_num_points', + css_class='col' + ), + css_class='form-row' + ), + Div( + Div( + 'dither_point_spacing', + css_class='col' + ), + Div( + 'dither_line_spacing', + css_class='col' + ), + css_class='form-row' + ), + Div( + Div( + 'dither_num_rows', + css_class='col' + ), + Div( + 'dither_num_columns', + css_class='col' + ), + css_class='form-row' + ), + Div( + Div( + 'dither_orientation', + css_class='col' + ), + Div( + 'dither_center', + css_class='col' + ), + css_class='form-row' + ), + css_id=f'{form_name}_dithering' + ), + Tab('Mosaicing', + Alert( + content="Mosaicing will only be applied if you have a single Configuration specified.", + css_class='alert-warning' + ), + Div( + Div( + 'mosaic_pattern', + css_class='col' + ), + Div( + 'mosaic_num_points', + css_class='col' + ), + css_class='form-row' + ), + Div( + Div( + 'mosaic_point_overlap', + css_class='col' + ), + Div( + 'mosaic_line_overlap', + css_class='col' + ), + css_class='form-row' + ), + Div( + Div( + 'mosaic_num_rows', + css_class='col' + ), + Div( + 'mosaic_num_columns', + css_class='col' + ), + css_class='form-row' + ), + Div( + Div( + 'mosaic_orientation', + css_class='col' + ), + Div( + 'mosaic_center', + css_class='col' + ), + css_class='form-row' + ), + css_id=f'{form_name}_mosaicing' + ) + ), + active=False, + css_id=f'{form_name}-expansions-group' + ) + ) + + +class OCSConfigurationLayout(Layout): + def __init__(self, form_name, facility_settings, instrument_config_layout_class, oe_groups, *args, **kwargs): + self.form_name = form_name + self.facility_settings = facility_settings + self.instrument_config_layout_class = instrument_config_layout_class + super().__init__( + Div( + HTML('''

Configurations:

''') + ), + TabHolder( + *self._get_config_tabs(oe_groups, facility_settings.get_setting('max_configurations')) + ) + ) + + def _get_config_tabs(self, oe_groups, num_tabs): + tabs = [] + for i in range(num_tabs): + tabs.append( + Tab(f'{i+1}', + *self._get_config_layout(i + 1, oe_groups), + css_id=f'{self.form_name}_config_{i+1}' + ), + ) + return tuple(tabs) + + def _get_config_layout(self, instance, oe_groups): + return ( + Alert( + content="""When using multiple configurations, ensure the instrument types are all + available on the same telescope class. + """, + css_class='alert-warning' + ), + Div( + Div( + f'c_{instance}_instrument_type', + css_class='col' + ), + Div( + f'c_{instance}_configuration_type', + css_class='col' + ), + css_class='form-row' + ), + Div( + Div( + f'c_{instance}_repeat_duration', + css_class='col' + ), + css_class='form-row' + ), + *self._get_target_override(instance), + Accordion( + *self.get_initial_accordion_items(instance), + AccordionGroup('Instrument Configurations', + self.instrument_config_layout_class(self.form_name, self.facility_settings, + instance, oe_groups), + css_id=f'{self.form_name}-c-{instance}-instrument-configs' + ), + AccordionGroup('Constraints', + Div( + Div( + f'c_{instance}_max_airmass', + css_class='col' + ), + css_class='form-row' + ), + Div( + Div( + f'c_{instance}_min_lunar_distance', + css_class='col' + ), + Div( + f'c_{instance}_max_lunar_phase', + css_class='col' + ), + css_class='form-row' + ), + ), + *self.get_final_accordion_items(instance) + ) + ) + + def get_initial_accordion_items(self, instance): + """ Override in subclasses to add items to the begining of the accordion group + """ + return () + + def get_final_accordion_items(self, instance): + """ Override in the subclasses to add items at the end of the accordion group + """ + return () + + def _get_target_override(self, instance): + if instance == 1: + return () + else: + return ( + Div( + f'c_{instance}_target_override', + css_class='form-row' + ) + ) + + +class OCSInstrumentConfigLayout(Layout): + def __init__(self, form_name, facility_settings, config_instance, oe_groups, *args, **kwargs): + self.form_name = form_name + self.facility_settings = facility_settings + super().__init__( + TabHolder( + *self._get_ic_tabs(config_instance, oe_groups, facility_settings.get_setting('max_instrument_configs')) + ) + ) + + def _get_ic_tabs(self, config_instance, oe_groups, num_tabs): + tabs = [] + for i in range(num_tabs): + tabs.append( + Tab(f'{i+1}', + *self._get_ic_layout(config_instance, i + 1, oe_groups), + css_id=f'{self.form_name}_c_{config_instance}_ic_{i+1}' + ), + ) + return tuple(tabs) + + def _get_oe_groups_layout(self, config_instance, instance, oe_groups): + oe_groups_layout = [] + for oe_group1, oe_group2 in zip(*[iter(oe_groups)] * 2): + oe_groups_layout.append( + Div( + Div( + f'c_{config_instance}_ic_{instance}_{oe_group1}', + css_class='col' + ), + Div( + f'c_{config_instance}_ic_{instance}_{oe_group2}', + css_class='col' + ), + css_class='form-row' + ) + ) + if len(oe_groups) % 2 == 1: + # We have one excess oe_group, so add it here + oe_groups_layout.append( + Div( + Div( + f'c_{config_instance}_ic_{instance}_{oe_groups[-1]}', + css_class='col' + ), + css_class='form-row' + ) + ) + return oe_groups_layout + + def _get_ic_layout(self, config_instance, instance, oe_groups): + return ( + Div( + Div( + f'c_{config_instance}_ic_{instance}_exposure_time', + css_class='col' + ), + Div( + f'c_{config_instance}_ic_{instance}_exposure_count', + css_class='col' + ), + css_class='form-row' + ), + *self._get_oe_groups_layout(config_instance, instance, oe_groups) + ) + + +class OCSBaseObservationForm(BaseRoboticObservationForm, OCSBaseForm): + """ + The OCSBaseObservationForm provides the base set of utilities to construct an observation at an OCS facility. + It must be subclassed to be used, as some methods are not implemented in this class. + """ + name = forms.CharField() + start = forms.CharField(widget=forms.TextInput(attrs={'type': 'date'})) + configuration_repeats = forms.IntegerField( + min_value=1, + initial=1, + required=False, + label='Configuration Repeats', + help_text='Number of times to repeat the set of configurations, usually used for nodding between 2+ targets' + ) + period = forms.FloatField(help_text='Decimal Hours', required=False, min_value=0.0) + jitter = forms.FloatField(help_text='Decimal Hours', required=False, min_value=0.0) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.fields['proposal'] = forms.ChoiceField(choices=self.proposal_choices()) + self.fields['ipp_value'] = forms.FloatField( + label='Intra Proposal Priority (IPP factor)', + min_value=0.5, + max_value=2, + initial=1.05, + help_text=self.facility_settings.ipp_value_help + ) + self.fields['end'] = forms.CharField(widget=forms.TextInput(attrs={'type': 'date'}), + help_text=self.facility_settings.end_help) + self.fields['observation_mode'] = forms.ChoiceField( + choices=(('NORMAL', 'Normal'), ('RAPID_RESPONSE', 'Rapid-Response'), ('TIME_CRITICAL', 'Time-Critical')), + help_text=self.facility_settings.observation_mode_help + ) + self.fields['optimization_type'] = forms.ChoiceField( + choices=(('TIME', 'Time'), ('AIRMASS', 'Airmass')), + required=False, + help_text=self.facility_settings.optimization_type_help + ) + # self.helper.layout = Layout( + # self.common_layout, + # self.layout(), + # self.button_layout() + # ) + + def clean_start(self): + start = self.cleaned_data['start'] + return parse(start).isoformat() + + def clean_end(self): + end = self.cleaned_data['end'] + return parse(end).isoformat() + + def validate_at_facility(self): + obs_module = get_service_class(self.cleaned_data['facility']) + response = obs_module().validate_observation(self.observation_payload()) + if response.get('request_durations', {}).get('duration'): + duration = response['request_durations']['duration'] + self.validation_message = f"This observation is valid with a duration of {duration} seconds." + if response.get('errors'): + self.add_error(None, self._flatten_error_dict(response['errors'])) + + def is_valid(self): + super().is_valid() + self.validate_at_facility() + if self._errors: + logger.warn(f'Facility submission has errors {self._errors}') + return not self._errors + + def _flatten_error_dict(self, error_dict): + non_field_errors = [] + for k, v in error_dict.items(): + if isinstance(v, list): + for i in v: + if isinstance(i, str): + if k in self.fields: + self.add_error(k, i) + else: + non_field_errors.append('{}: {}'.format(k, i)) + if isinstance(i, dict): + non_field_errors.append(self._flatten_error_dict(i)) + elif isinstance(v, str): + if k in self.fields: + self.add_error(k, v) + else: + non_field_errors.append('{}: {}'.format(k, v)) + elif isinstance(v, dict): + non_field_errors.append(self._flatten_error_dict(v)) + + return non_field_errors + + def _build_target_extra_params(self, configuration_id=1): + return {} + + def _build_target_fields(self, target_id, configuration_id=1): + target = Target.objects.get(pk=target_id) + target_fields = { + 'name': target.name, + } + if target.type == Target.SIDEREAL: + target_fields['type'] = 'ICRS' + target_fields['ra'] = target.ra + target_fields['dec'] = target.dec + target_fields['proper_motion_ra'] = target.pm_ra + target_fields['proper_motion_dec'] = target.pm_dec + target_fields['epoch'] = target.epoch + elif target.type == Target.NON_SIDEREAL: + target_fields['type'] = 'ORBITAL_ELEMENTS' + # Mapping from TOM field names to OCS API field names, for fields + # where there are differences + field_mapping = { + 'inclination': 'orbinc', + 'lng_asc_node': 'longascnode', + 'arg_of_perihelion': 'argofperih', + 'semimajor_axis': 'meandist', + 'mean_anomaly': 'meananom', + 'mean_daily_motion': 'dailymot', + 'epoch_of_elements': 'epochofel', + 'epoch_of_perihelion': 'epochofperih', + } + # The fields to include in the payload depend on the scheme. Add + # only those that are required + fields = (REQUIRED_NON_SIDEREAL_FIELDS + + REQUIRED_NON_SIDEREAL_FIELDS_PER_SCHEME[target.scheme]) + for field in fields: + ocs_field = field_mapping.get(field, field) + target_fields[ocs_field] = getattr(target, field) + + # + # Handle extra_params + # + if 'extra_params' not in target_fields: + target_fields['extra_params'] = {} + target_fields['extra_params'].update(self._build_target_extra_params(configuration_id)) + + return target_fields + + def _build_acquisition_config(self, configuration_id=1): + acquisition_config = {} + + return acquisition_config + + def _build_guiding_config(self, configuration_id=1): + guiding_config = {} + + return guiding_config + + def _build_instrument_configs(self): + return [] + + def _build_configuration(self): + configuration = { + 'type': self.instrument_to_default_configuration_type(self.cleaned_data['instrument_type']), + 'instrument_type': self.cleaned_data['instrument_type'], + 'target': self._build_target_fields(self.cleaned_data['target_id']), + 'instrument_configs': self._build_instrument_configs(), + 'acquisition_config': self._build_acquisition_config(), + 'guiding_config': self._build_guiding_config(), + 'constraints': { + 'max_airmass': self.cleaned_data['max_airmass'], + } + } + + if 'min_lunar_distance' in self.cleaned_data and self.cleaned_data.get('min_lunar_distance') is not None: + configuration['constraints']['min_lunar_distance'] = self.cleaned_data['min_lunar_distance'] + + return configuration + + def _build_location(self): + return {'telescope_class': self._get_instruments()[self.cleaned_data['instrument_type']]['class']} + + def _expand_cadence_request(self, payload): + payload['requests'][0]['cadence'] = { + 'start': self.cleaned_data['start'], + 'end': self.cleaned_data['end'], + 'period': self.cleaned_data['period'], + 'jitter': self.cleaned_data['jitter'] + } + payload['requests'][0]['windows'] = [] + + # use the OCS Observation Portal candence builder to build the candence + response = make_request( + 'POST', + urljoin(self.facility_settings.get_setting('portal_url'), '/api/requestgroups/cadence/'), + json=payload, + headers={'Authorization': 'Token {0}'.format(self.facility_settings.get_setting('api_key'))} + ) + return response.json() + + def observation_payload(self): + payload = { + 'name': self.cleaned_data['name'], + 'proposal': self.cleaned_data['proposal'], + 'ipp_value': self.cleaned_data['ipp_value'], + 'operator': 'SINGLE', + 'observation_type': self.cleaned_data['observation_mode'], + 'requests': [ + { + 'configurations': [self._build_configuration()], + 'windows': [ + { + 'start': self.cleaned_data['start'], + 'end': self.cleaned_data['end'] + } + ], + 'location': self._build_location() + } + ] + } + if self.cleaned_data.get('period') and self.cleaned_data.get('jitter') is not None: + payload = self._expand_cadence_request(payload) + + return payload + + +class OCSFullObservationForm(OCSBaseObservationForm): + """ + The OCSFullObservationForm has all capabilities to construct an observation using the OCS Request language. + While the forms that inherit from it provide a subset of instruments and filters, the + OCSFullObservationForm presents the user with all of the instrument and filter options that the facility has to + offer. + """ + dither_pattern = forms.ChoiceField( + choices=(('', 'None'), ('line', 'Line'), ('grid', 'Grid'), ('spiral', 'Spiral')), + required=False, + help_text='Expand your Instrument Configurations with a set of offsets from the target following a pattern.' + ) + dither_num_points = forms.IntegerField(min_value=2, label='Number of Points', + help_text='Number of Points in the pattern (Line and Spiral only).', + required=False) + dither_point_spacing = forms.FloatField( + label='Point Spacing', help_text='Vertical spacing between offsets.', required=False, min_value=0.0) + dither_line_spacing = forms.FloatField( + label='Line Spacing', help_text='Horizontal spacing between offsets (Grid only).', + required=False, min_value=0.0) + dither_orientation = forms.FloatField( + label='Orientation', + help_text='Angular rotation of the pattern in degrees, measured clockwise East of North (Line and Grid only).', + required=False, min_value=0.0) + dither_num_rows = forms.IntegerField( + min_value=1, label='Number of Rows', required=False, + help_text='Number of offsets in the pattern in the RA direction (Grid only).') + dither_num_columns = forms.IntegerField( + min_value=1, label='Number of Columns', required=False, + help_text='Number of offsets in the pattern in the declination direction (Grid only).') + dither_center = forms.ChoiceField( + choices=((True, 'True'), (False, 'False')), + label='Center', + required=False, + help_text='If True, pattern is centered on initial target. Otherwise pattern begins at initial target.' + ) + mosaic_pattern = forms.ChoiceField( + choices=(('', 'None'), ('line', 'Line'), ('grid', 'Grid')), + required=False, + help_text="""Expand your Configurations with a set of different targets following a mosaic pattern. + Only works with Sidereal targets. + """ + ) + mosaic_num_points = forms.IntegerField(min_value=2, label='Number of Points', + help_text='Number of Points in the pattern (Line only).', required=False) + mosaic_point_overlap = forms.FloatField( + label='Point Overlap Percent', + help_text='Percentage overlap of pointings in the pattern as a percent of declination in FOV.', + required=False, min_value=0.0, max_value=100.0) + mosaic_line_overlap = forms.FloatField( + label='Line Overlap Percent', + help_text='Percentage overlap of pointings in the pattern as a percent of RA in FOV (Grid only).', + required=False, min_value=0.0, max_value=100.0) + mosaic_orientation = forms.FloatField( + label='Orientation', + help_text='Angular rotation of the pattern in degrees, measured clockwise East of North.', + required=False, min_value=0.0) + mosaic_num_rows = forms.IntegerField( + min_value=1, label='Number of Rows', + help_text='Number of pointings in the pattern in the declination direction (Grid only).', required=False) + mosaic_num_columns = forms.IntegerField( + min_value=1, label='Number of Columns', + help_text='Number of pointings in the pattern in the RA direction (Grid only).', required=False) + mosaic_center = forms.ChoiceField( + choices=((True, 'True'), (False, 'False')), + label='Center', + required=False, + help_text='If True, pattern is centered on initial target. Otherwise pattern begins at initial target.' + ) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + for j in range(self.facility_settings.get_setting('max_configurations')): + self.fields[f'c_{j+1}_instrument_type'] = forms.ChoiceField( + choices=self.instrument_choices(), required=False, + help_text=self.facility_settings.instrument_type_help, + label='Instrument Type') + self.fields[f'c_{j+1}_configuration_type'] = forms.ChoiceField( + choices=self.configuration_type_choices(), required=False, label='Configuration Type') + self.fields[f'c_{j+1}_repeat_duration'] = forms.FloatField( + help_text=self.facility_settings.repeat_duration_help, required=False, label='Repeat Duration', + widget=forms.TextInput(attrs={'placeholder': 'Seconds'})) + self.fields[f'c_{j+1}_max_airmass'] = forms.FloatField( + help_text=self.facility_settings.max_airmass_help, label='Max Airmass', min_value=0, initial=1.6, + required=False) + self.fields[f'c_{j+1}_min_lunar_distance'] = forms.IntegerField( + min_value=0, label='Minimum Lunar Distance', required=False) + self.fields[f'c_{j+1}_max_lunar_phase'] = forms.FloatField( + help_text=self.facility_settings.max_lunar_phase_help, min_value=0, max_value=1.0, + label='Maximum Lunar Phase', required=False) + self.fields[f'c_{j+1}_target_override'] = forms.ChoiceField( + choices=self.target_group_choices(), + required=False, + help_text='Set a different target for this configuration. Must be in the same target group.', + label='Substitute Target for this Configuration' + ) + for i in range(self.facility_settings.get_setting('max_instrument_configs')): + self.fields[f'c_{j+1}_ic_{i+1}_exposure_count'] = forms.IntegerField( + min_value=1, label='Exposure Count', initial=1, required=False) + self.fields[f'c_{j+1}_ic_{i+1}_exposure_time'] = forms.FloatField( + min_value=0.1, label='Exposure Time', + widget=forms.TextInput(attrs={'placeholder': 'Seconds'}), + help_text=self.facility_settings.exposure_time_help, required=False) + for oe_group in self.get_optical_element_groups(): + oe_group_plural = oe_group + 's' + self.fields[f'c_{j+1}_ic_{i+1}_{oe_group}'] = forms.ChoiceField( + choices=self.filter_choices_for_group(oe_group_plural), required=False, + label=oe_group.replace('_', ' ').capitalize()) + self.helper.layout = Layout( + self.common_layout, + self.layout(), + self.button_layout() + ) + if isinstance(self, CadenceForm): + self.helper.layout.insert(2, self.cadence_layout()) + + def form_name(self): + return 'base' + + def instrument_config_layout_class(self): + return OCSInstrumentConfigLayout + + def configuration_layout_class(self): + return OCSConfigurationLayout + + def advanced_expansions_layout_class(self): + return OCSAdvancedExpansionsLayout + + def layout(self): + return Div( + Div( + Div( + 'name', + css_class='col' + ), + Div( + 'proposal', + css_class='col' + ), + css_class='form-row' + ), + Div( + Div( + 'observation_mode', + css_class='col' + ), + Div( + 'ipp_value', + css_class='col' + ), + css_class='form-row' + ), + Div( + Div( + 'optimization_type', + css_class='col' + ), + Div( + 'configuration_repeats', + css_class='col' + ), + css_class='form-row' + ), + Div( + Div( + 'start', + css_class='col' + ), + Div( + 'end', + css_class='col' + ), + css_class='form-row' + ), + self.configuration_layout_class()( + self.form_name(), self.facility_settings, self.instrument_config_layout_class(), + self.get_optical_element_groups() + ), + self.advanced_expansions_layout_class()(self.form_name(), self.facility_settings) + ) + + def _build_target_extra_params(self, configuration_id=1): + # if a fractional_ephemeris_rate has been specified, add it as an extra_param + # to the target_fields + if f'c_{configuration_id}_fractional_ephemeris_rate' in self.cleaned_data: + return {'fractional_ephemeris_rate': self.cleaned_data[f'c_{configuration_id}_fractional_ephemeris_rate']} + return {} + + def _build_instrument_config(self, instrument_type, configuration_id, id): + # If the instrument config did not have an exposure time set, leave it out by returning None + if not self.cleaned_data.get(f'c_{configuration_id}_ic_{id}_exposure_time'): + return None + instrument_config = { + 'exposure_count': self.cleaned_data[f'c_{configuration_id}_ic_{id}_exposure_count'], + 'exposure_time': self.cleaned_data[f'c_{configuration_id}_ic_{id}_exposure_time'], + 'optical_elements': {} + } + for oe_group in self.get_optical_element_groups(): + instrument_config['optical_elements'][oe_group] = self.cleaned_data.get( + f'c_{configuration_id}_ic_{id}_{oe_group}') + + return instrument_config + + def _build_instrument_configs(self, instrument_type, configuration_id): + ics = [] + for i in range(self.facility_settings.get_setting('max_instrument_configs')): + ic = self._build_instrument_config(instrument_type, configuration_id, i + 1) + # This will only include instrument configs with an exposure time set + if ic: + ics.append(ic) + + return ics + + def _build_configuration(self, id): + instrument_configs = self._build_instrument_configs(self.cleaned_data[f'c_{id}_instrument_type'], id) + # Check if the instrument configs are empty, and if so, leave this configuration out by returning None + if not instrument_configs: + return None + configuration = { + 'type': self.cleaned_data[f'c_{id}_configuration_type'], + 'instrument_type': self.cleaned_data[f'c_{id}_instrument_type'], + 'instrument_configs': instrument_configs, + 'acquisition_config': self._build_acquisition_config(id), + 'guiding_config': self._build_guiding_config(id), + 'constraints': { + 'max_airmass': self.cleaned_data[f'c_{id}_max_airmass'], + } + } + if self.cleaned_data.get(f'c_{id}_target_override'): + configuration['target'] = self._build_target_fields(self.cleaned_data[f'c_{id}_target_override']) + else: + configuration['target'] = self._build_target_fields(self.cleaned_data['target_id']) + if self.cleaned_data.get(f'c_{id}_repeat_duration'): + configuration['repeat_duration'] = self.cleaned_data[f'c_{id}_repeat_duration'] + if self.cleaned_data.get(f'c_{id}_min_lunar_distance'): + configuration['constraints']['min_lunar_distance'] = self.cleaned_data[f'c_{id}_min_lunar_distance'] + if self.cleaned_data.get(f'c_{id}_max_lunar_phase'): + configuration['constraints']['max_lunar_phase'] = self.cleaned_data[f'c_{id}_max_lunar_phase'] + + return configuration + + def _build_configurations(self): + configurations = [] + for j in range(self.facility_settings.get_setting('max_configurations')): + configuration = self._build_configuration(j+1) + if configuration: + configurations.append(configuration) + + return configurations + + def _expand_dither_pattern(self, configuration): + payload = { + 'configuration': configuration, + 'pattern': self.cleaned_data.get('dither_pattern'), + 'center': self.cleaned_data.get('dither_center') + } + if self.cleaned_data.get('dither_orientation'): + payload['orientation'] = self.cleaned_data['dither_orientation'] + if self.cleaned_data.get('dither_point_spacing'): + payload['point_spacing'] = self.cleaned_data['dither_point_spacing'] + if payload['pattern'] in ['line', 'spiral'] and self.cleaned_data.get('dither_num_points'): + payload['num_points'] = self.cleaned_data['dither_num_points'] + if payload['pattern'] == 'grid': + if self.cleaned_data.get('dither_num_rows'): + payload['num_rows'] = self.cleaned_data['dither_num_rows'] + if self.cleaned_data.get('dither_num_columns'): + payload['num_columns'] = self.cleaned_data['dither_num_columns'] + if self.cleaned_data.get('dither_line_spacing'): + payload['line_spacing'] = self.cleaned_data['dither_line_spacing'] + # Use the OCS Observation Portal dither pattern expansion to expand the configuration + response_json = {} + try: + response = make_request( + 'POST', + urljoin(self.facility_settings.get_setting('portal_url'), '/api/configurations/dither/'), + json=payload, + headers={'Authorization': 'Token {0}'.format(self.facility_settings.get_setting('api_key'))} + ) + response_json = response.json() + response.raise_for_status() + return response_json + except Exception: + logger.warning(f"Error expanding dither pattern: {response_json}") + return configuration + + def _expand_mosaic_pattern(self, request): + payload = { + 'request': request, + 'pattern': self.cleaned_data.get('mosaic_pattern'), + 'center': self.cleaned_data.get('mosaic_center') + } + if self.cleaned_data.get('mosaic_orientation'): + payload['orientation'] = self.cleaned_data['mosaic_orientation'] + if self.cleaned_data.get('mosaic_point_overlap'): + payload['point_overlap_percent'] = self.cleaned_data['mosaic_point_overlap'] + if payload['pattern'] == 'line' and self.cleaned_data.get('mosaic_num_points'): + payload['num_points'] = self.cleaned_data['mosaic_num_points'] + if payload['pattern'] == 'grid': + if self.cleaned_data.get('mosaic_num_rows'): + payload['num_rows'] = self.cleaned_data['mosaic_num_rows'] + if self.cleaned_data.get('mosaic_num_columns'): + payload['num_columns'] = self.cleaned_data['mosaic_num_columns'] + if self.cleaned_data.get('mosaic_line_overlap'): + payload['line_overlap_percent'] = self.cleaned_data['mosaic_line_overlap'] + + # Use the OCS Observation Portal dither pattern expansion to expand the configuration + response_json = {} + try: + response = make_request( + 'POST', + urljoin(self.facility_settings.get_setting('portal_url'), '/api/requests/mosaic/'), + json=payload, + headers={'Authorization': 'Token {0}'.format(self.facility_settings.get_setting('api_key'))} + ) + response_json = response.json() + response.raise_for_status() + return response_json + except Exception: + logger.warning(f"Error expanding mosaic pattern: {response_json}") + return request + + def _build_location(self, configuration_id=1): + return { + 'telescope_class': self._get_instruments()[ + self.cleaned_data[f'c_{configuration_id}_instrument_type']]['class'] + } + + def observation_payload(self): + payload = { + 'name': self.cleaned_data['name'], + 'proposal': self.cleaned_data['proposal'], + 'ipp_value': self.cleaned_data['ipp_value'], + 'operator': 'SINGLE', + 'observation_type': self.cleaned_data['observation_mode'], + 'requests': [ + { + 'optimization_type': self.cleaned_data['optimization_type'], + 'configuration_repeats': self.cleaned_data['configuration_repeats'], + 'configurations': self._build_configurations(), + 'windows': [ + { + 'start': self.cleaned_data['start'], + 'end': self.cleaned_data['end'] + } + ], + 'location': self._build_location() + } + ] + } + if (self.cleaned_data.get('dither_pattern') and self.cleaned_data.get('dither_point_spacing') and len( + payload['requests'][0]['configurations']) == 1): + payload['requests'][0]['configurations'][0] = self._expand_dither_pattern( + payload['requests'][0]['configurations'][0]) + if self.cleaned_data.get('mosaic_pattern') and len(payload['requests'][0]['configurations']) == 1: + payload['requests'][0] = self._expand_mosaic_pattern(payload['requests'][0]) + if self.cleaned_data.get('period') and self.cleaned_data.get('jitter') is not None: + payload = self._expand_cadence_request(payload) + + return payload + + +class OCSFacility(BaseRoboticObservationFacility): + """ + The ``OCSFacility`` is the interface to an OCS Observation Portal. For information regarding + OCS observing and the available parameters, please see https://observatorycontrolsystem.github.io/. + """ + name = 'OCS' + observation_forms = { + 'ALL': OCSFullObservationForm, + } + + def __init__(self, facility_settings=OCSSettings('OCS')): + self.facility_settings = facility_settings + super().__init__() + + # TODO: this should be called get_form_class + def get_form(self, observation_type): + return self.observation_forms.get(observation_type, OCSFullObservationForm) + + # TODO: this should be called get_template_form_class + def get_template_form(self, observation_type): + return OCSTemplateBaseForm + + def submit_observation(self, observation_payload): + response = make_request( + 'POST', + urljoin(self.facility_settings.get_setting('portal_url'), '/api/requestgroups/'), + json=observation_payload, + headers=self._portal_headers() + ) + return [r['id'] for r in response.json()['requests']] + + def validate_observation(self, observation_payload): + response = make_request( + 'POST', + urljoin(self.facility_settings.get_setting('portal_url'), '/api/requestgroups/validate/'), + json=observation_payload, + headers=self._portal_headers() + ) + return response.json() + + def cancel_observation(self, observation_id): + requestgroup_id = self._get_requestgroup_id(observation_id) + + response = make_request( + 'POST', + urljoin(self.facility_settings.get_setting('portal_url'), f'/api/requestgroups/{requestgroup_id}/cancel/'), + headers=self._portal_headers() + ) + + return response.json()['state'] == 'CANCELED' + + def get_observation_url(self, observation_id): + return urljoin(self.facility_settings.get_setting('portal_url'), f'/requests/{observation_id}') + + def get_flux_constant(self): + return self.facility_settings.get_data_flux_constant() + + def get_wavelength_units(self): + return self.facility_settings.get_data_wavelength_units() + + def get_date_obs_from_fits_header(self, header): + return header.get(self.facility_settings.get_fits_header_dateobs_keyword(), None) + + def is_fits_facility(self, header): + """ + Returns True if the keyword is in the given FITS header and contains the value specified, False + otherwise. + + :param header: FITS header object + :type header: dictionary-like + + :returns: True if header matches your OCS facility, False otherwise + :rtype: boolean + """ + return (self.facility_settings.get_fits_facility_header_value() == header.get( + self.facility_settings.get_fits_facility_header_keyword(), None)) + + def get_start_end_keywords(self): + return ('start', 'end') + + def get_terminal_observing_states(self): + return self.facility_settings.get_terminal_observing_states() + + def get_failed_observing_states(self): + return self.facility_settings.get_failed_observing_states() + + def get_observing_sites(self): + return self.facility_settings.get_sites() + + def get_facility_weather_urls(self): + """ + `facility_weather_urls = {'code': 'XYZ', 'sites': [ site_dict, ... ]}` + where + `site_dict = {'code': 'XYZ', 'weather_url': 'http://path/to/weather'}` + """ + return self.facility_settings.get_weather_urls() + + def get_facility_status(self): + """ + Get the telescope_states from the OCS API endpoint and simply + transform the returned JSON into the following dictionary hierarchy + for use by the facility_status.html template partial. + + facility_dict = {'code': 'OCS', 'sites': [ site_dict, ... ]} + site_dict = {'code': 'XYZ', 'telescopes': [ telescope_dict, ... ]} + telescope_dict = {'code': 'XYZ', 'status': 'AVAILABILITY'} + + Here's an example of the returned dictionary: + + literal_facility_status_example = { + 'code': 'OCS', + 'sites': [ + { + 'code': 'BPL', + 'telescopes': [ + { + 'code': 'bpl.doma.1m0a', + 'status': 'AVAILABLE' + }, + ], + }, + { + 'code': 'ELP', + 'telescopes': [ + { + 'code': 'elp.doma.1m0a', + 'status': 'AVAILABLE' + }, + { + 'code': 'elp.domb.1m0a', + 'status': 'AVAILABLE' + }, + ] + } + ] + } + + :return: facility_dict + """ + # make the request to the OCS API for the telescope_states + now = datetime.now() + response = make_request( + 'GET', + urljoin(self.facility_settings.get_setting('portal_url'), '/api/telescope_states/'), + headers=self._portal_headers() + ) + telescope_states = response.json() + logger.warning(f"Telescope states took {(datetime.now() - now).total_seconds()}") + # Now, transform the telescopes_state dictionary in a dictionary suitable + # for the facility_status.html template partial. + + # set up the return value to be populated by the for loop below + facility_status = { + 'code': self.name, + 'sites': [] + } + site_list = [site["sitecode"] for site in self.get_observing_sites().values()] + + for telescope_key, telescope_value in telescope_states.items(): + [site_code, _, _] = telescope_key.split('.') + + # limit returned sites to those provided by the facility + if site_code in site_list: + + # extract this telescope and it's status from the response + telescope = { + 'code': telescope_key, + 'status': telescope_value[0]['event_type'] + } + + # get the site dictionary from the facilities list of sites + # filter by site_code and provide a default (None) for new sites + site = next((site_ix for site_ix in facility_status['sites'] + if site_ix['code'] == site_code), None) + # create the site if it's new and not yet in the facility_status['sites] list + if site is None: + new_site = { + 'code': site_code, + 'telescopes': [] + } + facility_status['sites'].append(new_site) + site = new_site + + # Now, add the telescope to the site's telescopes + site['telescopes'].append(telescope) + + return facility_status + + def get_observation_status(self, observation_id): + response = make_request( + 'GET', + urljoin(self.facility_settings.get_setting('portal_url'), f'/api/requests/{observation_id}'), + headers=self._portal_headers() + ) + state = response.json()['state'] + + response = make_request( + 'GET', + urljoin(self.facility_settings.get_setting('portal_url'), f'/api/requests/{observation_id}/observations/'), + headers=self._portal_headers() + ) + blocks = response.json() + current_block = None + for block in blocks: + if block['state'] == 'COMPLETED': + current_block = block + break + elif block['state'] == 'PENDING': + current_block = block + if current_block: + scheduled_start = current_block['start'] + scheduled_end = current_block['end'] + else: + scheduled_start, scheduled_end = None, None + + return {'state': state, 'scheduled_start': scheduled_start, 'scheduled_end': scheduled_end} + + def data_products(self, observation_id, product_id=None): + products = [] + for frame in self._archive_frames(observation_id, product_id): + products.append({ + 'id': frame['id'], + 'filename': frame['filename'], + 'created': parse(frame['DATE_OBS']), + 'url': frame['url'] + }) + return products + + # The following methods are used internally by this module + # and should not be called directly from outside code. + + def _portal_headers(self): + if self.facility_settings.get_setting('api_key'): + return {'Authorization': f'Token {self.facility_settings.get_setting("api_key")}'} + else: + return {} + + def _get_requestgroup_id(self, observation_id): + query_params = urlencode({'request_id': observation_id}) + + response = make_request( + 'GET', + urljoin(self.facility_settings.get_setting('portal_url'), f'/api/requestgroups?{query_params}'), + headers=self._portal_headers() + ) + requestgroups = response.json() + + if requestgroups['count'] == 1: + return requestgroups['results'][0]['id'] + + def _archive_frames(self, observation_id, product_id=None): + # todo save this key somewhere + frames = [] + if product_id: + response = make_request( + 'GET', + urljoin(self.facility_settings.get_setting('archive_url'), f'/frames/{product_id}/'), + headers=self._portal_headers() + ) + frames = [response.json()] + else: + url = urljoin(self.facility_settings.get_setting('archive_url'), + f'/frames/?REQNUM={observation_id}&limit=1000') + while url: + response = make_request( + 'GET', + url, + headers=self._portal_headers() + ) + frames.extend(response.json()['results']) + url = response.json()['next'] + return frames diff --git a/tom_observations/facilities/soar.py b/tom_observations/facilities/soar.py index ec44ac1f4..1da83e3c6 100644 --- a/tom_observations/facilities/soar.py +++ b/tom_observations/facilities/soar.py @@ -1,26 +1,32 @@ import requests -from django.conf import settings -from tom_observations.facilities.lco import LCOFacility, LCOBaseForm +from tom_observations.facilities.lco import LCOFacility, LCOSettings from tom_observations.facilities.lco import LCOImagingObservationForm, LCOSpectroscopyObservationForm from tom_common.exceptions import ImproperCredentialsException -# Determine settings for this module. -try: - LCO_SETTINGS = settings.FACILITIES['LCO'] -except (AttributeError, KeyError): - LCO_SETTINGS = { - 'portal_url': 'https://observe.lco.global', - 'api_key': '', - } - -# Module specific settings. -PORTAL_URL = LCO_SETTINGS['portal_url'] -TERMINAL_OBSERVING_STATES = ['COMPLETED', 'CANCELED', 'WINDOW_EXPIRED'] +class SOARSettings(LCOSettings): + def get_sites(self): + return { + 'Cerro Pachón': { + 'sitecode': 'sor', + 'latitude': -30.237892, + 'longitude': -70.733642, + 'elevation': 2000 + } + } -# There is currently only one available grating, which is required for spectroscopy. -SPECTRAL_GRATING = 'SYZY_400' + def get_weather_urls(self): + return { + 'code': 'SOAR', + 'sites': [ + { + 'code': site['sitecode'], + 'weather_url': 'https://noirlab.edu/science/observing-noirlab/weather-webcams/' + 'cerro-pachon/environmental-conditions' + } + for site in self.get_sites().values()] + } def make_request(*args, **kwargs): @@ -34,7 +40,7 @@ def make_request(*args, **kwargs): class SOARImagingObservationForm(LCOImagingObservationForm): def get_instruments(self): - instruments = LCOBaseForm._get_instruments() + instruments = super()._get_instruments() return { code: instrument for (code, instrument) in instruments.items() if ( 'IMAGE' == instrument['type'] and 'SOAR' in code) @@ -46,7 +52,7 @@ def configuration_type_choices(self): class SOARSpectroscopyObservationForm(LCOSpectroscopyObservationForm): def get_instruments(self): - instruments = LCOBaseForm._get_instruments() + instruments = super()._get_instruments() return { code: instrument for (code, instrument) in instruments.items() if ( 'SPECTRA' == instrument['type'] and 'SOAR' in code) @@ -64,42 +70,14 @@ class SOARFacility(LCOFacility): Please note that SOAR is only available in AEON-mode. It also uses the LCO API key, so to use this module, the LCO dictionary in FACILITIES in `settings.py` will need to be completed. """ - name = 'SOAR' observation_forms = { 'IMAGING': SOARImagingObservationForm, 'SPECTRA': SOARSpectroscopyObservationForm } - # The SITES dictionary is used to calculate visibility intervals in the - # planning tool. All entries should contain latitude, longitude, elevation - # and a code. - SITES = { - 'Cerro Pachón': { - 'sitecode': 'sor', - 'latitude': -30.237892, - 'longitude': -70.733642, - 'elevation': 2000 - } - } + + def __init__(self, facility_settings=SOARSettings('LCO')): + super().__init__(facility_settings=facility_settings) def get_form(self, observation_type): return self.observation_forms.get(observation_type, SOARImagingObservationForm) - - def get_facility_weather_urls(self): - """ - `facility_weather_urls = {'code': 'XYZ', 'sites': [ site_dict, ... ]}` - where - `site_dict = {'code': 'XYZ', 'weather_url': 'http://path/to/weather'}` - """ - facility_weather_urls = { - 'code': 'SOAR', - 'sites': [ - { - 'code': site['sitecode'], - 'weather_url': 'https://noirlab.edu/science/observing-noirlab/weather-webcams/' - 'cerro-pachon/environmental-conditions' - } - for site in self.SITES.values()] - } - - return facility_weather_urls diff --git a/tom_observations/facility.py b/tom_observations/facility.py index 0cbdc16aa..2930bcfc3 100644 --- a/tom_observations/facility.py +++ b/tom_observations/facility.py @@ -10,16 +10,17 @@ from django.conf import settings from django.contrib.auth.models import Group from django.core.files.base import ContentFile +from django.utils.module_loading import import_string from tom_targets.models import Target logger = logging.getLogger(__name__) DEFAULT_FACILITY_CLASSES = [ - 'tom_observations.facilities.lco.LCOFacility', - 'tom_observations.facilities.gemini.GEMFacility', - 'tom_observations.facilities.soar.SOARFacility', - 'tom_observations.facilities.lt.LTFacility' + 'tom_observations.facilities.lco.LCOFacility', + 'tom_observations.facilities.gemini.GEMFacility', + 'tom_observations.facilities.soar.SOARFacility', + 'tom_observations.facilities.lt.LTFacility' ] try: @@ -36,10 +37,8 @@ def get_service_classes(): service_choices = {} for service in TOM_FACILITY_CLASSES: - mod_name, class_name = service.rsplit('.', 1) try: - mod = import_module(mod_name) - clazz = getattr(mod, class_name) + clazz = import_string(service) except (ImportError, AttributeError): raise ImportError('Could not import {}. Did you provide the correct path?'.format(service)) service_choices[clazz.name] = clazz @@ -91,11 +90,11 @@ def layout(self): def button_layout(self): target_id = self.initial.get('target_id') return ButtonHolder( - Submit('submit', 'Submit'), - Submit('validate', 'Validate'), - HTML(f''' - Back''') - ) + Submit('submit', 'Submit'), + Submit('validate', 'Validate'), + HTML(f''' + Back''') + ) def get_validation_message(self): """ Override this or self.validation_message to return a validation message that is shown when diff --git a/tom_observations/tests/facilities/test_lco.py b/tom_observations/tests/facilities/test_lco.py index 42dc37ba9..f3ef1c4c5 100644 --- a/tom_observations/tests/facilities/test_lco.py +++ b/tom_observations/tests/facilities/test_lco.py @@ -5,117 +5,13 @@ from django.test import TestCase -from tom_common.exceptions import ImproperCredentialsException -from tom_observations.facilities.lco import make_request, LCOFacility, LCOBaseForm -from tom_observations.facilities.lco import LCOTemplateBaseForm, LCOOldStyleObservationForm, LCOImagingObservationForm +from tom_observations.tests.facilities.test_ocs import instrument_response +from tom_observations.facilities.lco import LCOOldStyleObservationForm, LCOImagingObservationForm from tom_observations.facilities.lco import LCOPhotometricSequenceForm, LCOSpectroscopicSequenceForm from tom_observations.facilities.lco import LCOSpectroscopyObservationForm, LCOMuscatImagingObservationForm from tom_observations.tests.factories import SiderealTargetFactory, NonSiderealTargetFactory -instrument_response = { - '2M0-FLOYDS-SCICAM': { - 'type': 'SPECTRA', 'class': '2m0', 'name': '2.0 meter FLOYDS', 'optical_elements': { - 'slits': [ - {'name': '6.0 arcsec slit', 'code': 'slit_6.0as', 'schedulable': True, 'default': False}, - {'name': '1.6 arcsec slit', 'code': 'slit_1.6as', 'schedulable': True, 'default': False}, - {'name': '2.0 arcsec slit', 'code': 'slit_2.0as', 'schedulable': True, 'default': False}, - {'name': '1.2 arcsec slit', 'code': 'slit_1.2as', 'schedulable': True, 'default': False} - ] - }, - 'modes': { - 'rotator': { - 'type': 'rotator', - 'modes': [ - {'name': 'Sky Position', 'code': 'SKY'} - ] - } - } - }, - '1M0-NRES-SCICAM': { - 'type': 'SPECTRA', 'class': '1m0', 'name': '1.0 meter NRES', 'optical_elements': {} - }, - '0M4-SCICAM-SBIG': { - 'type': 'IMAGE', 'class': '0m4', 'name': '0.4 meter SBIG', 'optical_elements': { - 'filters': [ - {"name": "Bessell-U", "code": "U", "schedulable": True, "default": False}, - {"name": "Bessell-B", "code": "B", "schedulable": True, "default": False}, - {"name": "Bessell-V", "code": "V", "schedulable": True, "default": False}, - {"name": "Bessell-R", "code": "R", "schedulable": True, "default": False}, - {"name": "Bessell-I", "code": "I", "schedulable": True, "default": False}, - {"name": "SDSS-up", "code": "up", "schedulable": True, "default": False}, - {"name": "SDSS-gp", "code": "gp", "schedulable": True, "default": False}, - {"name": "SDSS-rp", "code": "rp", "schedulable": True, "default": False}, - {"name": "SDSS-ip", "code": "ip", "schedulable": True, "default": False}, - {"name": "PanSTARRS-Z", "code": "zs", "schedulable": True, "default": False}, - {"name": "PanSTARRS-w", "code": "w", "schedulable": True, "default": False}, - {'name': 'Opaque', 'code': 'opaque', 'schedulable': False, 'default': False}, - {'name': '100um Pinhole', 'code': '100um-Pinhole', 'schedulable': False, 'default': False}, - ] - }, - 'modes': { - 'guiding': { - 'type': 'guiding', - 'modes': [ - {'name': 'On', 'overhead': 0.0, 'code': 'ON'}, - {'name': 'Off', 'overhead': 0.0, 'code': 'OFF'}, - ], - 'default': 'ON' - } - } - }, - 'SOAR_GHTS_REDCAM': { - 'type': 'SPECTRA', 'class': '4m0', 'name': 'Goodman Spectrograph RedCam', 'optical_elements': { - 'gratings': [ - {'name': '400 line grating', 'code': 'SYZY_400', 'schedulable': True, 'default': True}, - ], - 'slits': [ - {'name': '1.0 arcsec slit', 'code': 'slit_1.0as', 'schedulable': True, 'default': True} - ] - }, - }, - '2M0-SCICAM-MUSCAT': { - 'type': 'IMAGE', 'class': '2m0', 'name': '2.0 meter Muscat', - 'optical_elements': { - 'diffuser_g_positions': [ - {'name': 'In Beam', 'code': 'in', 'schedulable': True, 'default': True}, - {'name': 'Out of Beam', 'code': 'out', 'schedulable': True, 'default': True} - ], - 'diffuser_r_positions': [ - {'name': 'In Beam', 'code': 'in', 'schedulable': True, 'default': False}, - {'name': 'Out of Beam', 'code': 'out', 'schedulable': True, 'default': True} - ], - 'diffuser_i_positions': [ - {'name': 'In Beam', 'code': 'in', 'schedulable': True, 'default': False}, - {'name': 'Out of Beam', 'code': 'out', 'schedulable': True, 'default': True} - ], - 'diffuser_z_positions': [ - {'name': 'In Beam', 'code': 'in', 'schedulable': True, 'default': False}, - {'name': 'Out of Beam', 'code': 'out', 'schedulable': True, 'default': True} - ] - }, - 'modes': { - 'guiding': { - 'type': 'guiding', - 'modes': [ - {'name': 'On', 'overhead': 0.0, 'code': 'ON'}, - {'name': 'Muscat G Guiding', 'overhead': 0.0, 'code': 'MUSCAT_G'}, - ], - 'default': 'ON' - }, - 'exposure': { - 'type': 'exposure', - 'modes': [ - {'name': 'Muscat Synchronous Exposure Mode', 'overhead': 0.0, 'code': 'SYNCHRONOUS'}, - {'name': 'Muscat Asynchronous Exposure Mode', 'overhead': 0.0, 'code': 'ASYNCHRONOUS'} - ], - 'default': 'SYNCHRONOUS' - } - } - } -} - - def generate_lco_instrument_choices(): return {k: v for k, v in instrument_response.items() if 'SOAR' not in k} @@ -124,92 +20,10 @@ def generate_lco_proposal_choices(): return [('sampleproposal', 'Sample Proposal')] -class TestMakeRequest(TestCase): - - @patch('tom_observations.facilities.lco.requests.request') - def test_make_request(self, mock_request): - mock_response = Response() - mock_response._content = str.encode(json.dumps({'test': 'test'})) - mock_response.status_code = 200 - mock_request.return_value = mock_response - - self.assertDictEqual({'test': 'test'}, make_request('GET', 'google.com', headers={'test': 'test'}).json()) - - mock_response.status_code = 403 - mock_request.return_value = mock_response - with self.assertRaises(ImproperCredentialsException): - make_request('GET', 'google.com', headers={'test': 'test'}) - - -class TestLCOBaseForm(TestCase): - - @patch('tom_observations.facilities.lco.make_request') - @patch('tom_observations.facilities.lco.cache') - def test_get_instruments(self, mock_cache, mock_make_request): - mock_response = Response() - mock_response._content = str.encode(json.dumps(instrument_response)) - mock_response.status_code = 200 - mock_make_request.return_value = mock_response - - # Test that cached value is returned - with self.subTest(): - test_instruments = {'test instrument': {'type': 'IMAGE'}} - mock_cache.get.return_value = test_instruments - - instruments = LCOTemplateBaseForm._get_instruments() - self.assertDictContainsSubset({'test instrument': {'type': 'IMAGE'}}, instruments) - self.assertNotIn('0M4-SCICAM-SBIG', instruments) - - # Test that empty cache results in mock_instruments, and cache.set is called - with self.subTest(): - mock_cache.get.return_value = None - - instruments = LCOTemplateBaseForm._get_instruments() - self.assertIn('0M4-SCICAM-SBIG', instruments) - self.assertDictContainsSubset({'type': 'IMAGE'}, instruments['0M4-SCICAM-SBIG']) - self.assertNotIn('SOAR_GHTS_REDCAM', instruments) - mock_cache.set.assert_called() - - @patch('tom_observations.facilities.lco.LCOBaseForm._get_instruments') - def test_instrument_choices(self, mock_get_instruments): - mock_get_instruments.return_value = generate_lco_instrument_choices() - form = LCOBaseForm() - - inst_choices = form.instrument_choices() - self.assertIn(('2M0-FLOYDS-SCICAM', '2.0 meter FLOYDS'), inst_choices) - self.assertIn(('0M4-SCICAM-SBIG', '0.4 meter SBIG'), inst_choices) - self.assertIn(('2M0-SCICAM-MUSCAT', '2.0 meter Muscat'), inst_choices) - self.assertEqual(len(inst_choices), 4) - - @patch('tom_observations.facilities.lco.LCOBaseForm._get_instruments') - def test_filter_choices(self, mock_get_instruments): - mock_get_instruments.return_value = generate_lco_instrument_choices() - form = LCOBaseForm() - - filter_choices = form.filter_choices() - for expected in [('rp', 'SDSS-rp'), ('R', 'Bessell-R'), ('slit_6.0as', '6.0 arcsec slit')]: - self.assertIn(expected, filter_choices) - self.assertEqual(len(filter_choices), 15) - - @patch('tom_observations.facilities.lco.make_request') - def test_proposal_choices(self, mock_make_request): - mock_response = Response() - mock_response._content = str.encode(json.dumps({'proposals': [ - {'id': 'ActiveProposal', 'title': 'Active', 'current': True}, - {'id': 'InactiveProposal', 'title': 'Inactive', 'current': False}] - })) - mock_response.status_code = 200 - mock_make_request.return_value = mock_response - - proposal_choices = LCOTemplateBaseForm.proposal_choices() - self.assertIn(('ActiveProposal', 'Active (ActiveProposal)'), proposal_choices) - self.assertNotIn(('InactiveProposal', 'Inactive (InactiveProposal)'), proposal_choices) - - -@patch('tom_observations.facilities.lco.LCOBaseObservationForm.proposal_choices') -@patch('tom_observations.facilities.lco.LCOBaseObservationForm.filter_choices') -@patch('tom_observations.facilities.lco.LCOBaseObservationForm.instrument_choices') -@patch('tom_observations.facilities.lco.LCOBaseObservationForm.validate_at_facility') +@patch('tom_observations.facilities.ocs.OCSBaseForm.proposal_choices') +@patch('tom_observations.facilities.lco.LCOOldStyleObservationForm.all_optical_element_choices') +@patch('tom_observations.facilities.ocs.OCSBaseObservationForm.instrument_choices') +@patch('tom_observations.facilities.ocs.OCSBaseObservationForm.validate_at_facility') class TestLCOOldStyleObservationForm(TestCase): def setUp(self): @@ -228,9 +42,6 @@ def setUp(self): ]) self.proposal_choices = generate_lco_proposal_choices() - def test_validate_at_facility(self, mock_validate, mock_insts, mock_filters, mock_proposals): - pass - def test_clean_and_validate(self, mock_validate, mock_insts, mock_filters, mock_proposals): """Test clean_start, clean_end, and is_valid()""" mock_validate.return_value = [] @@ -269,12 +80,6 @@ def test_flatten_error_dict(self, mock_validate, mock_insts, mock_filters, mock_ self.assertIn('Invalid airmass', form.errors['max_airmass']) self.assertIn(['test_key: dict_error'], flattened_errors) - def test_instrument_to_type(self, mock_validate, mock_insts, mock_filters, mock_proposals): - """Test instrument_to_type method.""" - self.assertEqual('SPECTRUM', LCOOldStyleObservationForm.instrument_to_type('2M0-FLOYDS-SCICAM')) - self.assertEqual('NRES_SPECTRUM', LCOOldStyleObservationForm.instrument_to_type('1M0-NRES-SCICAM')) - self.assertEqual('EXPOSE', LCOOldStyleObservationForm.instrument_to_type('0M4-SCICAM-SBIG')) - def test_build_target_fields(self, mock_validate, mock_insts, mock_filters, mock_proposals): """Test _build_target_fields method.""" mock_validate.return_value = [] @@ -328,7 +133,7 @@ def test_build_instrument_config(self, mock_validate, mock_insts, mock_filters, 'exposure_time': self.valid_form_data['exposure_time'], 'optical_elements': {'filter': self.valid_form_data['filter']} }], - form._build_instrument_config() + form._build_instrument_configs() ) def test_build_acquisition_config(self, mock_validate, mock_insts, mock_filters, mock_proposals): @@ -355,8 +160,10 @@ def test_build_guiding_config(self, mock_validate, mock_insts, mock_filters, moc # This should but does not mock instrument_to_type, _build_target_fields, _build_instrument_config, # _build_acquisition_config, and _build_guiding_config - def test_build_configuration(self, mock_validate, mock_insts, mock_filters, mock_proposals): + @patch('tom_observations.facilities.ocs.OCSBaseForm._get_instruments') + def test_build_configuration(self, mock_get_instruments, mock_validate, mock_insts, mock_filters, mock_proposals): """Test _build_configuration method.""" + mock_get_instruments.return_value = generate_lco_instrument_choices() mock_validate.return_value = [] mock_insts.return_value = self.instrument_choices mock_filters.return_value = self.filter_choices @@ -372,7 +179,7 @@ def test_build_configuration(self, mock_validate, mock_insts, mock_filters, mock for key in ['target', 'instrument_configs', 'acquisition_config', 'guiding_config']: self.assertIn(key, configuration) - @patch('tom_observations.facilities.lco.LCOBaseForm._get_instruments') + @patch('tom_observations.facilities.ocs.OCSBaseForm._get_instruments') def test_build_location(self, mock_get_instruments, mock_validate, mock_insts, mock_filters, mock_proposals): """Test _build_location method.""" mock_get_instruments.return_value = generate_lco_instrument_choices() @@ -388,9 +195,9 @@ def test_build_location(self, mock_get_instruments, mock_validate, mock_insts, m def test_expand_cadence_request(self, mock_validate, mock_insts, mock_filters, mock_proposals): pass - @patch('tom_observations.facilities.lco.LCOBaseObservationForm._build_location') - @patch('tom_observations.facilities.lco.LCOBaseObservationForm._build_configuration') - @patch('tom_observations.facilities.lco.make_request') + @patch('tom_observations.facilities.ocs.OCSBaseObservationForm._build_location') + @patch('tom_observations.facilities.ocs.OCSBaseObservationForm._build_configuration') + @patch('tom_observations.facilities.ocs.make_request') def test_observation_payload(self, mock_make_request, mock_build_configuration, mock_build_location, mock_validate, mock_insts, mock_filters, mock_proposals): """Test observation_payload method.""" @@ -446,8 +253,8 @@ def test_observation_payload(self, mock_make_request, mock_build_configuration, self.assertDictEqual({'test': 'test_static_cadence'}, form.observation_payload()) -@patch('tom_observations.facilities.lco.LCOBaseForm.proposal_choices') -@patch('tom_observations.facilities.lco.LCOBaseForm._get_instruments') +@patch('tom_observations.facilities.ocs.OCSBaseForm.proposal_choices') +@patch('tom_observations.facilities.ocs.OCSBaseForm._get_instruments') class TestLCOImagingObservationForm(TestCase): def setUp(self): self.st = SiderealTargetFactory.create() @@ -484,9 +291,8 @@ def test_filter_choices(self, mock_get_instruments, mock_get_proposals): @patch('tom_observations.facilities.lco.LCOMuscatImagingObservationForm.validate_at_facility') -@patch('tom_observations.facilities.lco.LCOMuscatImagingObservationForm.filter_choices') -@patch('tom_observations.facilities.lco.LCOBaseForm.proposal_choices') -@patch('tom_observations.facilities.lco.LCOBaseForm._get_instruments') +@patch('tom_observations.facilities.ocs.OCSBaseForm.proposal_choices') +@patch('tom_observations.facilities.ocs.OCSBaseForm._get_instruments') class TestLCOMuscatImagingObservationForm(TestCase): def setUp(self): @@ -501,7 +307,7 @@ def setUp(self): 'c_1_instrument_type': '2M0-SCICAM-MUSCAT', 'c_1_max_airmass': 3.0 } - def test_instrument_choices(self, mock_get_instruments, mock_get_proposals, mock_get_filters, mock_validate): + def test_instrument_choices(self, mock_get_instruments, mock_get_proposals, mock_validate): """Test LCOMuscatImagingObservationForm._instrument_choices.""" mock_get_instruments.return_value = generate_lco_instrument_choices() form = LCOMuscatImagingObservationForm(self.valid_form_data) @@ -509,7 +315,7 @@ def test_instrument_choices(self, mock_get_instruments, mock_get_proposals, mock self.assertIn(('2M0-SCICAM-MUSCAT', '2.0 meter Muscat'), inst_choices) self.assertEqual(len(inst_choices), 1) - def test_diffuser_choices(self, mock_get_instruments, mock_get_proposals, mock_get_filters, mock_validate): + def test_diffuser_choices(self, mock_get_instruments, mock_get_proposals, mock_validate): """Test LCOMuscatImagingObservationForm.diffuser_position_choices.""" mock_get_instruments.return_value = generate_lco_instrument_choices() for channel in ['g', 'r', 'i', 'z']: @@ -521,7 +327,7 @@ def test_diffuser_choices(self, mock_get_instruments, mock_get_proposals, mock_g self.assertIn(('out', 'Out of Beam'), diffuser_choices) self.assertTrue(len(diffuser_choices), 2) - def test_exposure_mode_choices(self, mock_get_instruments, mock_get_proposals, mock_get_filters, mock_validate): + def test_exposure_mode_choices(self, mock_get_instruments, mock_get_proposals, mock_validate): """Test LCOMuscatImagingObservationForm.mode_choices for exposure modes.""" mock_get_instruments.return_value = generate_lco_instrument_choices() form = LCOMuscatImagingObservationForm(self.valid_form_data) @@ -530,7 +336,7 @@ def test_exposure_mode_choices(self, mock_get_instruments, mock_get_proposals, m self.assertIn(('ASYNCHRONOUS', 'Muscat Asynchronous Exposure Mode'), exposure_mode_choices) self.assertEqual(len(exposure_mode_choices), 2) - def test_guide_mode_choices(self, mock_get_instruments, mock_get_proposals, mock_get_filters, mock_validate): + def test_guide_mode_choices(self, mock_get_instruments, mock_get_proposals, mock_validate): """Test LCOMuscatImagingObservationForm.mode_choices for guiding modes.""" mock_get_instruments.return_value = generate_lco_instrument_choices() form = LCOMuscatImagingObservationForm(self.valid_form_data) @@ -539,7 +345,7 @@ def test_guide_mode_choices(self, mock_get_instruments, mock_get_proposals, mock self.assertIn(('ON', 'On'), guide_mode_choices) self.assertEqual(len(guide_mode_choices), 2) - def test_build_instrument_config(self, mock_get_instruments, mock_get_proposals, mock_get_filters, mock_validate): + def test_build_instrument_config(self, mock_get_instruments, mock_get_proposals, mock_validate): """Test LCOMuscatImagingObservationForm._build_instrument_config.""" mock_get_instruments.return_value = generate_lco_instrument_choices() mock_get_proposals.return_value = generate_lco_proposal_choices() @@ -567,7 +373,7 @@ def test_build_instrument_config(self, mock_get_instruments, mock_get_proposals, 'diffuser_z_position': 'in' }, instrument_config['optical_elements']) - def test_build_guiding_config(self, mock_get_instruments, mock_get_proposals, mock_get_filters, mock_validate): + def test_build_guiding_config(self, mock_get_instruments, mock_get_proposals, mock_validate): """Test LCOMuscatImagingObservationForm._build_guiding_config.""" mock_get_instruments.return_value = generate_lco_instrument_choices() mock_get_proposals.return_value = generate_lco_proposal_choices() @@ -589,8 +395,8 @@ def setUp(self): 'c_1_ic_1_rotator_mode': 'SKY', 'c_1_ic_1_rotator_angle': 1.0 } - @patch('tom_observations.facilities.lco.LCOBaseForm.proposal_choices') - @patch('tom_observations.facilities.lco.LCOBaseForm._get_instruments') + @patch('tom_observations.facilities.ocs.OCSBaseForm.proposal_choices') + @patch('tom_observations.facilities.ocs.OCSBaseForm._get_instruments') def test_instrument_choices(self, mock_get_instruments, mock_get_proposals): """Test LCOSpectroscopyObservationForm._instrument_choices.""" mock_get_instruments.return_value = generate_lco_instrument_choices() @@ -603,15 +409,15 @@ def test_instrument_choices(self, mock_get_instruments, mock_get_proposals): self.assertNotIn(('0M4-SCICAM-SBIG', '0.4 meter SBIG'), inst_choices) self.assertEqual(len(inst_choices), 2) - @patch('tom_observations.facilities.lco.LCOBaseForm.proposal_choices') - @patch('tom_observations.facilities.lco.LCOBaseForm._get_instruments') + @patch('tom_observations.facilities.ocs.OCSBaseForm.proposal_choices') + @patch('tom_observations.facilities.ocs.OCSBaseForm._get_instruments') def test_filter_choices(self, mock_get_instruments, mock_get_proposals): """Test LCOSpectroscopyObservationForm._filter_choices.""" mock_get_instruments.return_value = generate_lco_instrument_choices() mock_get_proposals.return_value = generate_lco_proposal_choices() form = LCOSpectroscopyObservationForm(self.valid_form_data) - filter_choices = form.filter_choices() + filter_choices = form.filter_choices_for_group('slits') for expected in [('slit_6.0as', '6.0 arcsec slit'), ('slit_1.6as', '1.6 arcsec slit'), ('slit_2.0as', '2.0 arcsec slit'), ('slit_1.2as', '1.2 arcsec slit')]: self.assertIn(expected, filter_choices) @@ -619,9 +425,9 @@ def test_filter_choices(self, mock_get_instruments, mock_get_proposals): self.assertNotIn(not_expected, filter_choices) self.assertEqual(len(filter_choices), 4) - @patch('tom_observations.facilities.lco.LCOBaseForm.proposal_choices') - @patch('tom_observations.facilities.lco.LCOSpectroscopyObservationForm.filter_choices') - @patch('tom_observations.facilities.lco.LCOBaseForm._get_instruments') + @patch('tom_observations.facilities.ocs.OCSBaseForm.proposal_choices') + @patch('tom_observations.facilities.ocs.OCSBaseForm.filter_choices_for_group') + @patch('tom_observations.facilities.ocs.OCSBaseForm._get_instruments') @patch('tom_observations.facilities.lco.LCOSpectroscopyObservationForm.validate_at_facility') def test_build_instrument_config(self, mock_validate, mock_insts, mock_filters, mock_proposals): mock_validate.return_value = [] @@ -629,7 +435,7 @@ def test_build_instrument_config(self, mock_validate, mock_insts, mock_filters, mock_filters.return_value = set([ (f['code'], f['name']) for ins in instrument_response.values() for f in ins['optical_elements'].get('slits', []) - ]) + ]) mock_proposals.return_value = generate_lco_proposal_choices() # Test that optical_elements['slit'] is populated when filter is included @@ -680,18 +486,20 @@ def setUp(self): ) self.proposal_choices = generate_lco_proposal_choices() - @patch('tom_observations.facilities.lco.LCOPhotometricSequenceForm._get_instruments') - def test_instrument_choices(self, mock_get_instruments): + @patch('tom_observations.facilities.ocs.OCSBaseForm.proposal_choices') + @patch('tom_observations.facilities.ocs.OCSBaseForm._get_instruments') + def test_instrument_choices(self, mock_get_instruments, mock_proposals): """Test LCOPhotometricSequenceForm._instrument_choices.""" mock_get_instruments.return_value = generate_lco_instrument_choices() + form = LCOPhotometricSequenceForm() - inst_choices = LCOPhotometricSequenceForm.instrument_choices() + inst_choices = form.instrument_choices() self.assertIn(('0M4-SCICAM-SBIG', '0.4 meter SBIG'), inst_choices) self.assertNotIn(('2M0-FLOYDS-SCICAM', '2.0 meter FLOYDS'), inst_choices) self.assertEqual(len(inst_choices), 1) - @patch('tom_observations.facilities.lco.LCOPhotometricSequenceForm.proposal_choices') - @patch('tom_observations.facilities.lco.LCOPhotometricSequenceForm.filter_choices') + @patch('tom_observations.facilities.ocs.OCSBaseForm.proposal_choices') + @patch('tom_observations.facilities.lco.LCOPhotometricSequenceForm.all_optical_element_choices') @patch('tom_observations.facilities.lco.LCOPhotometricSequenceForm.instrument_choices') @patch('tom_observations.facilities.lco.LCOPhotometricSequenceForm.validate_at_facility') def test_build_instrument_config(self, mock_validate, mock_insts, mock_filters, mock_proposals): @@ -702,13 +510,13 @@ def test_build_instrument_config(self, mock_validate, mock_insts, mock_filters, form = LCOPhotometricSequenceForm(self.valid_form_data) self.assertTrue(form.is_valid()) - inst_config = form._build_instrument_config() + inst_config = form._build_instrument_configs() self.assertEqual(len(inst_config), 2) self.assertIn({'exposure_count': 1, 'exposure_time': 30.0, 'optical_elements': {'filter': 'U'}}, inst_config) self.assertIn({'exposure_count': 2, 'exposure_time': 60.0, 'optical_elements': {'filter': 'B'}}, inst_config) - @patch('tom_observations.facilities.lco.LCOPhotometricSequenceForm.proposal_choices') - @patch('tom_observations.facilities.lco.LCOPhotometricSequenceForm.filter_choices') + @patch('tom_observations.facilities.ocs.OCSBaseForm.proposal_choices') + @patch('tom_observations.facilities.lco.LCOPhotometricSequenceForm.all_optical_element_choices') @patch('tom_observations.facilities.lco.LCOPhotometricSequenceForm.instrument_choices') @patch('tom_observations.facilities.lco.LCOPhotometricSequenceForm.validate_at_facility') def test_clean(self, mock_validate, mock_insts, mock_filters, mock_proposals): @@ -751,22 +559,26 @@ def setUp(self): ]) self.proposal_choices = generate_lco_proposal_choices() - @patch('tom_observations.facilities.lco.LCOSpectroscopicSequenceForm._get_instruments') - def test_instrument_choices(self, mock_get_instruments): + @patch('tom_observations.facilities.ocs.OCSBaseForm.proposal_choices') + @patch('tom_observations.facilities.ocs.OCSBaseForm._get_instruments') + def test_instrument_choices(self, mock_get_instruments, mock_proposals): """Test LCOSpectroscopicSequenceForm._instrument_choices.""" mock_get_instruments.return_value = generate_lco_instrument_choices() + form = LCOSpectroscopicSequenceForm() - inst_choices = LCOSpectroscopicSequenceForm.instrument_choices() + inst_choices = form.instrument_choices() self.assertIn(('2M0-FLOYDS-SCICAM', '2.0 meter FLOYDS'), inst_choices) self.assertNotIn(('0M4-SCICAM-SBIG', '0.4 meter SBIG'), inst_choices) self.assertEqual(len(inst_choices), 1) - @patch('tom_observations.facilities.lco.LCOSpectroscopicSequenceForm._get_instruments') - def test_filter_choices(self, mock_get_instruments): + @patch('tom_observations.facilities.ocs.OCSBaseForm.proposal_choices') + @patch('tom_observations.facilities.ocs.OCSBaseForm._get_instruments') + def test_filter_choices(self, mock_get_instruments, mock_proposals): """Test LCOSpectroscopicSequenceForm._instrument_choices.""" mock_get_instruments.return_value = generate_lco_instrument_choices() + form = LCOSpectroscopicSequenceForm() - filter_choices = LCOSpectroscopicSequenceForm.filter_choices() + filter_choices = form.all_optical_element_choices() for expected in [('slit_6.0as', '6.0 arcsec slit'), ('slit_1.6as', '1.6 arcsec slit'), ('slit_2.0as', '2.0 arcsec slit'), ('slit_1.2as', '1.2 arcsec slit')]: self.assertIn(expected, filter_choices) @@ -774,8 +586,8 @@ def test_filter_choices(self, mock_get_instruments): self.assertNotIn(not_expected, filter_choices) self.assertEqual(len(filter_choices), 4) - @patch('tom_observations.facilities.lco.LCOSpectroscopicSequenceForm.proposal_choices') - @patch('tom_observations.facilities.lco.LCOSpectroscopicSequenceForm.filter_choices') + @patch('tom_observations.facilities.ocs.OCSBaseForm.proposal_choices') + @patch('tom_observations.facilities.lco.LCOSpectroscopicSequenceForm.all_optical_element_choices') @patch('tom_observations.facilities.lco.LCOSpectroscopicSequenceForm.instrument_choices') @patch('tom_observations.facilities.lco.LCOSpectroscopicSequenceForm.validate_at_facility') def test_build_instrument_config(self, mock_validate, mock_insts, mock_filters, mock_proposals): @@ -786,14 +598,14 @@ def test_build_instrument_config(self, mock_validate, mock_insts, mock_filters, form = LCOSpectroscopicSequenceForm(self.valid_form_data) self.assertTrue(form.is_valid()) - inst_config = form._build_instrument_config() + inst_config = form._build_instrument_configs() self.assertEqual(len(inst_config), 1) self.assertIn({'exposure_count': 1, 'exposure_time': 30.0, 'optical_elements': {'slit': 'slit_1.2as'}}, inst_config) self.assertNotIn('filter', inst_config[0]['optical_elements']) - @patch('tom_observations.facilities.lco.LCOSpectroscopicSequenceForm.proposal_choices') - @patch('tom_observations.facilities.lco.LCOSpectroscopicSequenceForm.filter_choices') + @patch('tom_observations.facilities.ocs.OCSBaseForm.proposal_choices') + @patch('tom_observations.facilities.lco.LCOSpectroscopicSequenceForm.all_optical_element_choices') @patch('tom_observations.facilities.lco.LCOSpectroscopicSequenceForm.instrument_choices') @patch('tom_observations.facilities.lco.LCOSpectroscopicSequenceForm.validate_at_facility') def test_build_acquisition_config(self, mock_validate, mock_insts, mock_filters, mock_proposals): @@ -816,8 +628,8 @@ def test_build_acquisition_config(self, mock_validate, mock_insts, mock_filters, acquisition_config = form._build_acquisition_config() self.assertDictEqual({'mode': 'WCS'}, acquisition_config) - @patch('tom_observations.facilities.lco.LCOSpectroscopicSequenceForm.proposal_choices') - @patch('tom_observations.facilities.lco.LCOSpectroscopicSequenceForm.filter_choices') + @patch('tom_observations.facilities.ocs.OCSBaseForm.proposal_choices') + @patch('tom_observations.facilities.lco.LCOSpectroscopicSequenceForm.all_optical_element_choices') @patch('tom_observations.facilities.lco.LCOSpectroscopicSequenceForm.instrument_choices') @patch('tom_observations.facilities.lco.LCOSpectroscopicSequenceForm.validate_at_facility') def test_build_guiding_config(self, mock_validate, mock_insts, mock_filters, mock_proposals): @@ -839,8 +651,8 @@ def test_build_guiding_config(self, mock_validate, mock_insts, mock_filters, moc guiding_config = form._build_guiding_config() self.assertDictEqual(params[1], guiding_config) - @patch('tom_observations.facilities.lco.LCOSpectroscopicSequenceForm.proposal_choices') - @patch('tom_observations.facilities.lco.LCOSpectroscopicSequenceForm.filter_choices') + @patch('tom_observations.facilities.ocs.OCSBaseForm.proposal_choices') + @patch('tom_observations.facilities.lco.LCOSpectroscopicSequenceForm.all_optical_element_choices') @patch('tom_observations.facilities.lco.LCOSpectroscopicSequenceForm.instrument_choices') @patch('tom_observations.facilities.lco.LCOSpectroscopicSequenceForm.validate_at_facility') @patch('tom_observations.facilities.lco.LCOSpectroscopicSequenceForm._get_instruments') @@ -864,8 +676,8 @@ def test_build_location(self, mock_get_instruments, mock_validate, mock_insts, m location = form._build_location() self.assertDictContainsSubset(params[1], location) - @patch('tom_observations.facilities.lco.LCOSpectroscopicSequenceForm.proposal_choices') - @patch('tom_observations.facilities.lco.LCOSpectroscopicSequenceForm.filter_choices') + @patch('tom_observations.facilities.ocs.OCSBaseForm.proposal_choices') + @patch('tom_observations.facilities.lco.LCOSpectroscopicSequenceForm.all_optical_element_choices') @patch('tom_observations.facilities.lco.LCOSpectroscopicSequenceForm.instrument_choices') @patch('tom_observations.facilities.lco.LCOSpectroscopicSequenceForm.validate_at_facility') def test_clean(self, mock_validate, mock_insts, mock_filters, mock_proposals): @@ -890,44 +702,3 @@ def test_clean(self, mock_validate, mock_insts, mock_filters, mock_proposals): self.valid_form_data.pop('target_id') form = LCOSpectroscopicSequenceForm(self.valid_form_data) self.assertFalse(form.is_valid()) - - -class TestLCOObservationTemplateForm(TestCase): - pass - - -class TestLCOFacility(TestCase): - def setUp(self): - self.lco = LCOFacility() - - @patch('tom_observations.facilities.lco.make_request') - def test_get_requestgroup_id(self, mock_make_request): - mock_response = Response() - mock_response._content = str.encode(json.dumps({ - 'count': 1, - 'results': [{ - 'id': 1073496 - }] - })) - mock_response.status_code = 200 - mock_make_request.return_value = mock_response - - with self.subTest('Test that a correct response results in a valid id.'): - requestgroup_id = self.lco._get_requestgroup_id(1234567) - self.assertEqual(requestgroup_id, 1073496) - - with self.subTest('Test that an empty response results in no id.'): - mock_response._content = str.encode(json.dumps({ - 'count': 0, - 'results': [] - })) - requestgroup_id = self.lco._get_requestgroup_id(1234567) - self.assertIsNone(requestgroup_id) - - with self.subTest('Test that multiple results returns no id.'): - mock_response._content = str.encode(json.dumps({ - 'count': 2, - 'results': [{'id': 1073496}, {'id': 1073497}] - })) - requestgroup_id = self.lco._get_requestgroup_id(1234567) - self.assertIsNone(requestgroup_id) diff --git a/tom_observations/tests/facilities/test_ocs.py b/tom_observations/tests/facilities/test_ocs.py new file mode 100644 index 000000000..77098f3a1 --- /dev/null +++ b/tom_observations/tests/facilities/test_ocs.py @@ -0,0 +1,256 @@ +from requests import Response +from unittest.mock import patch +import json + +from django.test import TestCase +from tom_observations.facilities.ocs import (make_request, OCSBaseForm, OCSFacility, OCSTemplateBaseForm) +from tom_common.exceptions import ImproperCredentialsException + + +instrument_response = { + '2M0-FLOYDS-SCICAM': { + 'type': 'SPECTRA', 'class': '2m0', 'name': '2.0 meter FLOYDS', 'optical_elements': { + 'slits': [ + {'name': '6.0 arcsec slit', 'code': 'slit_6.0as', 'schedulable': True, 'default': False}, + {'name': '1.6 arcsec slit', 'code': 'slit_1.6as', 'schedulable': True, 'default': False}, + {'name': '2.0 arcsec slit', 'code': 'slit_2.0as', 'schedulable': True, 'default': False}, + {'name': '1.2 arcsec slit', 'code': 'slit_1.2as', 'schedulable': True, 'default': False} + ] + }, + 'modes': { + 'rotator': { + 'type': 'rotator', + 'modes': [ + {'name': 'Sky Position', 'code': 'SKY'} + ] + } + }, + 'default_configuration_type': 'SPECTRUM' + }, + '1M0-NRES-SCICAM': { + 'type': 'SPECTRA', 'class': '1m0', 'name': '1.0 meter NRES', + 'optical_elements': {}, + 'default_configuration_type': 'NRES_SPECTRUM' + }, + '0M4-SCICAM-SBIG': { + 'type': 'IMAGE', 'class': '0m4', 'name': '0.4 meter SBIG', 'optical_elements': { + 'filters': [ + {"name": "Bessell-U", "code": "U", "schedulable": True, "default": False}, + {"name": "Bessell-B", "code": "B", "schedulable": True, "default": False}, + {"name": "Bessell-V", "code": "V", "schedulable": True, "default": False}, + {"name": "Bessell-R", "code": "R", "schedulable": True, "default": False}, + {"name": "Bessell-I", "code": "I", "schedulable": True, "default": False}, + {"name": "SDSS-up", "code": "up", "schedulable": True, "default": False}, + {"name": "SDSS-gp", "code": "gp", "schedulable": True, "default": False}, + {"name": "SDSS-rp", "code": "rp", "schedulable": True, "default": False}, + {"name": "SDSS-ip", "code": "ip", "schedulable": True, "default": False}, + {"name": "PanSTARRS-Z", "code": "zs", "schedulable": True, "default": False}, + {"name": "PanSTARRS-w", "code": "w", "schedulable": True, "default": False}, + {'name': 'Opaque', 'code': 'opaque', 'schedulable': False, 'default': False}, + {'name': '100um Pinhole', 'code': '100um-Pinhole', 'schedulable': False, 'default': False}, + ] + }, + 'modes': { + 'guiding': { + 'type': 'guiding', + 'modes': [ + {'name': 'On', 'overhead': 0.0, 'code': 'ON'}, + {'name': 'Off', 'overhead': 0.0, 'code': 'OFF'}, + ], + 'default': 'ON' + } + }, + 'default_configuration_type': 'EXPOSE' + }, + 'SOAR_GHTS_REDCAM': { + 'type': 'SPECTRA', 'class': '4m0', 'name': 'Goodman Spectrograph RedCam', 'optical_elements': { + 'gratings': [ + {'name': '400 line grating', 'code': 'SYZY_400', 'schedulable': True, 'default': True}, + ], + 'slits': [ + {'name': '1.0 arcsec slit', 'code': 'slit_1.0as', 'schedulable': True, 'default': True} + ] + }, + 'default_configuration_type': 'SPECTRUM' + }, + '2M0-SCICAM-MUSCAT': { + 'type': 'IMAGE', 'class': '2m0', 'name': '2.0 meter Muscat', + 'optical_elements': { + 'diffuser_g_positions': [ + {'name': 'In Beam', 'code': 'in', 'schedulable': True, 'default': True}, + {'name': 'Out of Beam', 'code': 'out', 'schedulable': True, 'default': True} + ], + 'diffuser_r_positions': [ + {'name': 'In Beam', 'code': 'in', 'schedulable': True, 'default': False}, + {'name': 'Out of Beam', 'code': 'out', 'schedulable': True, 'default': True} + ], + 'diffuser_i_positions': [ + {'name': 'In Beam', 'code': 'in', 'schedulable': True, 'default': False}, + {'name': 'Out of Beam', 'code': 'out', 'schedulable': True, 'default': True} + ], + 'diffuser_z_positions': [ + {'name': 'In Beam', 'code': 'in', 'schedulable': True, 'default': False}, + {'name': 'Out of Beam', 'code': 'out', 'schedulable': True, 'default': True} + ] + }, + 'modes': { + 'guiding': { + 'type': 'guiding', + 'modes': [ + {'name': 'On', 'overhead': 0.0, 'code': 'ON'}, + {'name': 'Muscat G Guiding', 'overhead': 0.0, 'code': 'MUSCAT_G'}, + ], + 'default': 'ON' + }, + 'exposure': { + 'type': 'exposure', + 'modes': [ + {'name': 'Muscat Synchronous Exposure Mode', 'overhead': 0.0, 'code': 'SYNCHRONOUS'}, + {'name': 'Muscat Asynchronous Exposure Mode', 'overhead': 0.0, 'code': 'ASYNCHRONOUS'} + ], + 'default': 'SYNCHRONOUS' + } + }, + 'default_configuration_type': 'EXPOSE' + } +} + +def generate_ocs_instrument_choices(): + return {k: v for k, v in instrument_response.items()} + + +def generate_ocs_proposal_choices(): + return [('sampleproposal', 'Sample Proposal')] + + +class TestMakeRequest(TestCase): + + @patch('tom_observations.facilities.ocs.requests.request') + def test_make_request(self, mock_request): + mock_response = Response() + mock_response._content = str.encode(json.dumps({'test': 'test'})) + mock_response.status_code = 200 + mock_request.return_value = mock_response + + self.assertDictEqual({'test': 'test'}, make_request('GET', 'google.com', headers={'test': 'test'}).json()) + + mock_response.status_code = 403 + mock_request.return_value = mock_response + with self.assertRaises(ImproperCredentialsException): + make_request('GET', 'google.com', headers={'test': 'test'}) + + +class TestOCSBaseForm(TestCase): + @patch('tom_observations.facilities.ocs.OCSBaseForm.proposal_choices') + @patch('tom_observations.facilities.ocs.make_request') + @patch('tom_observations.facilities.ocs.cache') + def test_get_instruments(self, mock_cache, mock_make_request, mock_proposals): + mock_response = Response() + mock_response._content = str.encode(json.dumps(generate_ocs_instrument_choices())) + mock_response.status_code = 200 + mock_make_request.return_value = mock_response + + # Test that cached value is returned + with self.subTest(): + test_instruments = {'test instrument': {'type': 'IMAGE', 'name': 'Test Instrument'}} + mock_cache.get.return_value = test_instruments + form = OCSTemplateBaseForm() + + instruments = form._get_instruments() + self.assertDictContainsSubset(test_instruments, instruments) + self.assertNotIn('0M4-SCICAM-SBIG', instruments) + + # Test that empty cache results in mock_instruments, and cache.set is called + with self.subTest(): + mock_cache.get.return_value = None + form = OCSTemplateBaseForm() + + instruments = form._get_instruments() + self.assertIn('0M4-SCICAM-SBIG', instruments) + self.assertIn('SOAR_GHTS_REDCAM', instruments) + self.assertDictContainsSubset({'type': 'IMAGE'}, instruments['0M4-SCICAM-SBIG']) + mock_cache.set.assert_called() + + @patch('tom_observations.facilities.ocs.OCSBaseForm._get_instruments') + def test_instrument_to_type(self, mock_get_instruments): + mock_get_instruments.return_value = generate_ocs_instrument_choices() + """Test instrument_to_type method.""" + form = OCSBaseForm() + self.assertEqual('SPECTRUM', form.instrument_to_default_configuration_type('2M0-FLOYDS-SCICAM')) + self.assertEqual('NRES_SPECTRUM', form.instrument_to_default_configuration_type('1M0-NRES-SCICAM')) + self.assertEqual('EXPOSE', form.instrument_to_default_configuration_type('0M4-SCICAM-SBIG')) + + @patch('tom_observations.facilities.ocs.OCSBaseForm._get_instruments') + def test_instrument_choices(self, mock_get_instruments): + mock_get_instruments.return_value = generate_ocs_instrument_choices() + form = OCSBaseForm() + + inst_choices = form.instrument_choices() + self.assertIn(('2M0-FLOYDS-SCICAM', '2.0 meter FLOYDS'), inst_choices) + self.assertIn(('0M4-SCICAM-SBIG', '0.4 meter SBIG'), inst_choices) + self.assertIn(('2M0-SCICAM-MUSCAT', '2.0 meter Muscat'), inst_choices) + self.assertEqual(len(inst_choices), 5) + + @patch('tom_observations.facilities.ocs.OCSBaseForm._get_instruments') + def test_filter_choices(self, mock_get_instruments, ): + mock_get_instruments.return_value = generate_ocs_instrument_choices() + form = OCSBaseForm() + + filter_choices = form.all_optical_element_choices() + for expected in [('rp', 'SDSS-rp'), ('R', 'Bessell-R'), ('slit_6.0as', '6.0 arcsec slit')]: + self.assertIn(expected, filter_choices) + self.assertEqual(len(filter_choices), 19) + + @patch('tom_observations.facilities.ocs.OCSBaseForm._get_instruments') + @patch('tom_observations.facilities.ocs.make_request') + def test_proposal_choices(self, mock_make_request, mock_get_instruments): + mock_get_instruments.return_value = generate_ocs_instrument_choices() + mock_response = Response() + mock_response._content = str.encode(json.dumps({'proposals': [ + {'id': 'ActiveProposal', 'title': 'Active', 'current': True}, + {'id': 'InactiveProposal', 'title': 'Inactive', 'current': False}] + })) + mock_response.status_code = 200 + mock_make_request.return_value = mock_response + form = OCSTemplateBaseForm() + + proposal_choices = form.proposal_choices() + self.assertIn(('ActiveProposal', 'Active (ActiveProposal)'), proposal_choices) + self.assertNotIn(('InactiveProposal', 'Inactive (InactiveProposal)'), proposal_choices) + + +class TestOCSFacility(TestCase): + def setUp(self): + self.lco = OCSFacility() + + @patch('tom_observations.facilities.ocs.make_request') + def test_get_requestgroup_id(self, mock_make_request): + mock_response = Response() + mock_response._content = str.encode(json.dumps({ + 'count': 1, + 'results': [{ + 'id': 1073496 + }] + })) + mock_response.status_code = 200 + mock_make_request.return_value = mock_response + + with self.subTest('Test that a correct response results in a valid id.'): + requestgroup_id = self.lco._get_requestgroup_id(1234567) + self.assertEqual(requestgroup_id, 1073496) + + with self.subTest('Test that an empty response results in no id.'): + mock_response._content = str.encode(json.dumps({ + 'count': 0, + 'results': [] + })) + requestgroup_id = self.lco._get_requestgroup_id(1234567) + self.assertIsNone(requestgroup_id) + + with self.subTest('Test that multiple results returns no id.'): + mock_response._content = str.encode(json.dumps({ + 'count': 2, + 'results': [{'id': 1073496}, {'id': 1073497}] + })) + requestgroup_id = self.lco._get_requestgroup_id(1234567) + self.assertIsNone(requestgroup_id) diff --git a/tom_observations/tests/facilities/test_soar.py b/tom_observations/tests/facilities/test_soar.py index 2f9e95efb..0d8d1be79 100644 --- a/tom_observations/tests/facilities/test_soar.py +++ b/tom_observations/tests/facilities/test_soar.py @@ -14,12 +14,13 @@ '2M0-FLOYDS-SCICAM': { 'type': 'SPECTRA', 'class': '2m0', 'name': '2.0 meter FLOYDS', 'optical_elements': { 'slits': [ - {'name': '6.0 arcsec slit', 'code': 'slit_6.0as', 'schedulable': True, 'default': False}, - {'name': '1.6 arcsec slit', 'code': 'slit_1.6as', 'schedulable': True, 'default': False}, - {'name': '2.0 arcsec slit', 'code': 'slit_2.0as', 'schedulable': True, 'default': False}, - {'name': '1.2 arcsec slit', 'code': 'slit_1.2as', 'schedulable': True, 'default': False} + {'name': '6.0 arcsec slit', 'code': 'slit_6.0as', 'schedulable': True, 'default': False}, + {'name': '1.6 arcsec slit', 'code': 'slit_1.6as', 'schedulable': True, 'default': False}, + {'name': '2.0 arcsec slit', 'code': 'slit_2.0as', 'schedulable': True, 'default': False}, + {'name': '1.2 arcsec slit', 'code': 'slit_1.2as', 'schedulable': True, 'default': False} ] - } + }, + 'default_configuration_type': 'SPECTRUM' }, '0M4-SCICAM-SBIG': { 'type': 'IMAGE', 'class': '0m4', 'name': '0.4 meter SBIG', 'optical_elements': { @@ -28,6 +29,7 @@ {'name': '100um Pinhole', 'code': '100um-Pinhole', 'schedulable': False, 'default': False}, ] }, + 'default_configuration_type': 'EXPOSE' }, 'SOAR_GHTS_REDCAM': { 'type': 'SPECTRA', 'class': '4m0', 'name': 'Goodman Spectrograph RedCam', 'optical_elements': { @@ -45,7 +47,8 @@ {'name': 'Sky Position', 'code': 'SKY'} ] } - } + }, + 'default_configuration_type': 'SPECTRUM' }, 'SOAR_GHTS_REDCAM_IMAGER': { 'type': 'IMAGE', 'class': '4m0', 'name': 'Goodman Spectrograph RedCam Imager', 'optical_elements': { @@ -59,6 +62,7 @@ {'name': 'GHTS VR', 'code': 'VR', 'schedulable': True, 'default': False} ] }, + 'default_configuration_type': 'EXPOSE' } } @@ -80,8 +84,8 @@ def test_make_request(self, mock_request): make_request('GET', 'google.com', headers={'test': 'test'}) -@patch('tom_observations.facilities.lco.LCOBaseForm.proposal_choices') -@patch('tom_observations.facilities.lco.LCOBaseForm._get_instruments') +@patch('tom_observations.facilities.ocs.OCSBaseForm.proposal_choices') +@patch('tom_observations.facilities.ocs.OCSBaseForm._get_instruments') class TestSOARImagingObservationForm(TestCase): def setUp(self): self.st = SiderealTargetFactory.create() @@ -130,8 +134,8 @@ def setUp(self): 'c_1_instrument_type': 'SOAR_GHTS_REDCAM', 'c_1_ic_1_rotator_angle': 1.0 } - @patch('tom_observations.facilities.lco.LCOBaseForm.proposal_choices') - @patch('tom_observations.facilities.lco.LCOBaseForm._get_instruments') + @patch('tom_observations.facilities.ocs.OCSBaseForm.proposal_choices') + @patch('tom_observations.facilities.ocs.OCSBaseForm._get_instruments') def test_instrument_choices(self, mock_get_instruments, mock_proposals): """Test SOARSpectroscopyObservationForm._instrument_choices.""" mock_proposals.return_value = [('sampleproposal', 'Sample Proposal')] @@ -143,8 +147,8 @@ def test_instrument_choices(self, mock_get_instruments, mock_proposals): self.assertNotIn(('SOAR_GHTS_REDCAM_IMAGER', 'Goodman Spectrograph RedCam Imager'), inst_choices) self.assertEqual(len(inst_choices), 1) - @patch('tom_observations.facilities.lco.LCOBaseForm.proposal_choices') - @patch('tom_observations.facilities.lco.LCOBaseForm._get_instruments') + @patch('tom_observations.facilities.ocs.OCSBaseForm.proposal_choices') + @patch('tom_observations.facilities.ocs.OCSBaseForm._get_instruments') def test_slit_choices(self, mock_get_instruments, mock_proposals): """Test SOARSpectroscopyObservationForm slit choices""" mock_proposals.return_value = [('sampleproposal', 'Sample Proposal')] @@ -158,8 +162,8 @@ def test_slit_choices(self, mock_get_instruments, mock_proposals): self.assertNotIn(not_expected, slit_choices) self.assertEqual(len(slit_choices), 1) - @patch('tom_observations.facilities.lco.LCOBaseForm.proposal_choices') - @patch('tom_observations.facilities.lco.LCOBaseForm._get_instruments') + @patch('tom_observations.facilities.ocs.OCSBaseForm.proposal_choices') + @patch('tom_observations.facilities.ocs.OCSBaseForm._get_instruments') @patch('tom_observations.facilities.soar.SOARSpectroscopyObservationForm.validate_at_facility') def test_build_instrument_config(self, mock_validate, mock_insts, mock_proposals): mock_validate.return_value = {} diff --git a/tom_observations/tests/test_cadence.py b/tom_observations/tests/test_cadence.py index d361788b5..2b42b49e8 100644 --- a/tom_observations/tests/test_cadence.py +++ b/tom_observations/tests/test_cadence.py @@ -9,14 +9,15 @@ from tom_observations.cadences.retry_failed_observations import RetryFailedObservationsStrategy -mock_filters = { +mock_instruments = { '1M0-SCICAM-SINISTRO': { 'type': 'IMAGE', 'class': '1m0', 'name': '1.0 meter Sinistro', 'optical_elements': { 'filters': [{'name': 'Bessell-I', 'code': 'I', 'schedulable': True, 'default': True}] - } + }, + 'default_configuration_type': 'EXPOSE' } } @@ -36,9 +37,8 @@ 'instrument_type': '1M0-SCICAM-SINISTRO' } - -@patch('tom_observations.facilities.lco.LCOBaseForm._get_instruments', return_value=mock_filters) -@patch('tom_observations.facilities.lco.LCOBaseForm.proposal_choices', +@patch('tom_observations.facilities.ocs.OCSBaseForm._get_instruments', return_value=mock_instruments) +@patch('tom_observations.facilities.ocs.OCSBaseForm.proposal_choices', return_value=[('LCOSchedulerTest', 'LCOSchedulerTest')]) @patch('tom_observations.facilities.lco.LCOFacility.submit_observation', return_value=[198132]) @patch('tom_observations.facilities.lco.LCOFacility.validate_observation') diff --git a/tom_observations/views.py b/tom_observations/views.py index 7e40afec0..e7b929671 100644 --- a/tom_observations/views.py +++ b/tom_observations/views.py @@ -247,7 +247,7 @@ def get_initial(self): def post(self, request, *args, **kwargs): form = self.get_form() if form.is_valid(): - if 'validateButton' in request.POST: + if 'validate' in request.POST: return self.form_validation_valid(form) else: return self.form_valid(form)