diff --git a/custom_code/facilities/lco_facility.py b/custom_code/facilities/lco_facility.py index 679caab8..ff902361 100644 --- a/custom_code/facilities/lco_facility.py +++ b/custom_code/facilities/lco_facility.py @@ -435,12 +435,10 @@ def __init__(self, *args, **kwargs): self.initial['cadence_strategy'] = 'SnexRetryUntilDeadlineStrategy' self.initial['retry_until_obtained'] = True - self.fields['instrument_type'] = forms.ChoiceField( - choices=self.instrument_choices(), - required=False, - initial='2M0-FLOYDS-SCICAM', - widget=forms.HiddenInput() - ) + self.fields['instrument_type'] = forms.ChoiceField(choices=self.instrument_choices(), + required=False, + initial='2M0-FLOYDS-SCICAM') + self.fields['instrument_type'].widget = forms.HiddenInput() self.fields['start'] = forms.CharField(widget=forms.TextInput(attrs={'type': 'date'})) self.fields['end'] = forms.CharField(widget=forms.TextInput(attrs={'type': 'date'})) @@ -498,6 +496,9 @@ def __init__(self, *args, **kwargs): self.button_layout() ) + def instrument_choices(self): + return [('2M0-FLOYDS-SCICAM', '2.0 meter FLOYDS')] + def clean(self): cadence_value = self.cleaned_data.get('cadence_frequency_value') cadence_unit = (self.cleaned_data.get('cadence_frequency_unit') or '').lower() @@ -639,4 +640,4 @@ class SnexLCOFacility(LCOFacility): } def __init__(self, facility_settings=LCOSettings('LCO')): - super().__init__(facility_settings=facility_settings) \ No newline at end of file + super().__init__(facility_settings=facility_settings) diff --git a/custom_code/facilities/soar_facility.py b/custom_code/facilities/soar_facility.py index 099ed202..bed1d1bb 100644 --- a/custom_code/facilities/soar_facility.py +++ b/custom_code/facilities/soar_facility.py @@ -1,31 +1,35 @@ import copy -import requests +import json +import logging +from datetime import timedelta, datetime -from tom_common.exceptions import ImproperCredentialsException -from tom_observations.facilities.soar import SOARFacility, SOARSpectroscopyObservationForm -from tom_observations.facilities.lco import LCOSpectroscopyObservationForm +import requests +from crispy_forms.bootstrap import PrependedText +from crispy_forms.layout import Column, Div, HTML, Layout, Row from django import forms -import datetime from django.conf import settings -from crispy_forms.layout import Layout, Div, HTML, Column -from crispy_forms.bootstrap import PrependedAppendedText, PrependedText, AppendedText +from tom_common.exceptions import ImproperCredentialsException +from tom_observations.facilities.soar import ( + SOARFacility as BaseSOARFacility, + SOARSpectroscopyObservationForm, +) from tom_targets.models import Target -# Determine settings for this module. + try: - LCO_SETTINGS = settings.FACILITIES['LCO'] + SOAR_SETTINGS = settings.FACILITIES['SOAR'] except (AttributeError, KeyError): - LCO_SETTINGS = { + SOAR_SETTINGS = { 'portal_url': 'https://observe.lco.global', 'api_key': '', + 'access_group_name': 'PASSTA', } -# Module specific settings. -PORTAL_URL = LCO_SETTINGS['portal_url'] +PORTAL_URL = SOAR_SETTINGS['portal_url'] TERMINAL_OBSERVING_STATES = ['COMPLETED', 'CANCELED', 'WINDOW_EXPIRED'] +SOAR_GROUP_NAME = SOAR_SETTINGS.get('access_group_name', 'PASSTA') +logger = logging.getLogger(__name__) -# There is currently only one available grating, which is required for spectroscopy. -#SPECTRAL_GRATING = 'SYZY_400' def make_request(*args, **kwargs): response = requests.request(*args, **kwargs) @@ -34,202 +38,535 @@ def make_request(*args, **kwargs): response.raise_for_status() return response +def user_can_access_soar(user): + return bool( + getattr(user, 'is_authenticated', False) + and user.groups.filter(name=SOAR_GROUP_NAME).exists() + ) -class SOARObservationForm(SOARSpectroscopyObservationForm, LCOSpectroscopyObservationForm): +class SOARObservationForm(SOARSpectroscopyObservationForm): + max_airmass = forms.FloatField(initial=1.6, min_value=0, required=False, label='Max Airmass') + min_lunar_distance = forms.IntegerField(initial=20, min_value=0, required=False, label='Minimum Lunar Distance') + rotator_angle = forms.FloatField(initial=0.0, required=False, widget=forms.HiddenInput()) + exposure_count = forms.IntegerField( + initial=2, + min_value=1, + required=False, + label='', + widget=forms.NumberInput(attrs={'min': 1}) + ) + exposure_time = forms.FloatField( + min_value=0.1, + label='', + widget=forms.TextInput(attrs={'placeholder': 'Seconds'}) + ) + observation_mode = forms.ChoiceField( + choices=(('NORMAL', 'Normal'), ('TARGET_OF_OPPORTUNITY', 'Rapid Response')), + initial='NORMAL', + required=False, + widget=forms.HiddenInput() + ) + grating = forms.CharField(required=False, widget=forms.HiddenInput()) + filter = forms.CharField(required=False, widget=forms.HiddenInput()) + readout = forms.ChoiceField(choices=(), required=False, label='Readout') + name = forms.CharField(required=False, widget=forms.HiddenInput()) + + INSTRUMENT_DEFAULTS = { + 'SOAR_GHTS_REDCAM': { + 'exposure_time': 450, + 'max_airmass': 1.6, + 'exposure_count': 2, + 'readout': 'GHTS_R_400m2_2x2', + 'rotator_angle': 0, + }, + 'SOAR_GHTS_BLUECAM': { + 'exposure_time': 450, + 'max_airmass': 1.6, + 'exposure_count': 2, + 'readout': 'GHTS_B_400m1_2x2', + 'rotator_angle': 0, + }, + 'SOAR_TRIPLESPEC': { + 'exposure_time': 200, + 'max_airmass': 1.6, + 'exposure_count': 6, + 'readout': 'fowler16_coadds1', + 'rotator_angle': 90, + }, + } - # Auto set name, need to check exp getting submitted correctly - # Use validate endpoint to check, print submission - # Field for exp time, exp count, rotator angle + READOUT_CHOICES_BY_INSTRUMENT = { + 'SOAR_GHTS_REDCAM': [ + ('GHTS_R_400m1_2x2', 'GHTS_R_400m1_2x2'), + ('GHTS_R_400m2_2x2', 'GHTS_R_400m2_2x2'), + ('GHTS_R_400m1_2x2_slit1p5', 'GHTS_R_400m1_2x2_slit1p5'), + ('GHTS_R_2100_5000A_1x2_slit1p0', 'GHTS_R_2100_5000A_1x2_slit1p0'), + ], + 'SOAR_GHTS_BLUECAM': [ + ('GHTS_B_400m1_2x2', 'GHTS_B_400m1_2x2'), + ('GHTS_B_400m1_2x2_slit1p5', 'GHTS_B_400m1_2x2_slit1p5'), + ], + 'SOAR_TRIPLESPEC': [ + ('fowler1_coadds1', 'fowler1_coadds1'), + ('fowler1_coadds2', 'fowler1_coadds2'), + ('fowler4_coadds1', 'fowler4_coadds1'), + ('fowler8_coadds1', 'fowler8_coadds1'), + ('fowler16_coadds1', 'fowler16_coadds1'), + ], + } - window = forms.FloatField(initial=3.0,label='',min_value=0.0) - ipp_value = forms.FloatField(initial=1.0,label='',min_value=0.5,max_value=2.0) - max_airmass = forms.FloatField(initial=1.6,label='',min_value=1.0) - min_lunar_distance = forms.IntegerField(min_value=0, label='', initial=20) - rotator_angle= forms.FloatField(initial=0.0,label='',min_value=0.0) - exposure_count = forms.IntegerField(initial=1,label='',min_value=1) - exposure_time = forms.FloatField(min_value=0.1,label='', - widget=forms.TextInput(attrs={'placeholder': 'Seconds'})) - observation_mode= forms.ChoiceField( - choices=(('NORMAL', 'Normal'), ('TARGET_OF_OPPORTUNITY', 'Rapid Response')), - label='Priority' + HIDDEN_FIELDS = ( + 'name', + 'facility', + 'target_id', + 'observation_type', + 'rotator_angle', + 'observation_mode', + 'grating', + 'filter', ) - delay_start = forms.BooleanField(required=False, label='Delay Start By') - delay_amount = forms.FloatField(initial=0.0, min_value=0, label='', required=False) - # These are required fields in the base LCO form, so I need to include them but will ignore - start = forms.CharField(widget=forms.TextInput(attrs={'type': 'date'}), required=False, label='') - end = forms.CharField(widget=forms.TextInput(attrs={'type': 'date'}), required=False, label='') - name = forms.CharField(initial='SOAR Observation', required=False, label='') + PRIMARY_TO_ALIAS_FIELDS = { + 'instrument_type': ('c_1_instrument_type',), + 'exposure_time': ('c_1_ic_1_exposure_time',), + 'exposure_count': ('c_1_ic_1_exposure_count',), + 'readout': ('c_1_ic_1_mode',), + 'rotator_angle': ('c_1_ic_1_rotator_angle',), + 'max_airmass': ('c_1_max_airmass',), + 'min_lunar_distance': ('c_1_min_lunar_distance',), + } + + def instrument_choices(self): + return [ + (code, instrument["name"]) + for code, instrument in self.get_instruments().items() + ] + + @staticmethod + def _prepare_form_kwargs(kwargs): + data = kwargs.get('data') + if data is not None and hasattr(data, 'copy'): + kwargs = kwargs.copy() + copied_data = data.copy() + for field_name in ( + 'facility', + 'target_id', + 'observation_type', + 'name', + 'instrument_type', + 'exposure_time', + 'ipp_value', + 'proposal', + 'observation_mode', + 'start', + 'end', + ): + if hasattr(copied_data, 'getlist'): + values = [value for value in copied_data.getlist(field_name) if value not in (None, '')] + if values: + copied_data.setlist(field_name, [values[-1]]) + SOARObservationForm._synchronize_primary_and_alias_data(copied_data) + kwargs['data'] = copied_data + return kwargs + + @classmethod + def _synchronize_primary_and_alias_data(cls, data): + if not hasattr(data, 'getlist'): + return data + + for primary_name, alias_names in cls.PRIMARY_TO_ALIAS_FIELDS.items(): + primary_values = [value for value in data.getlist(primary_name) if value not in (None, '')] + alias_values = [] + for alias_name in alias_names: + alias_values.extend( + [value for value in data.getlist(alias_name) if value not in (None, '')] + ) + + canonical_value = None + if primary_values: + canonical_value = primary_values[-1] + elif alias_values: + canonical_value = alias_values[-1] + + if canonical_value in (None, ''): + continue + + data.setlist(primary_name, [canonical_value]) + for alias_name in alias_names: + data.setlist(alias_name, [canonical_value]) + + return data + + def _field_value(self, name, aliases=(), default=None, include_initial=True): + candidate_keys = (name, *aliases) + sources = [] + + if hasattr(self, 'cleaned_data'): + sources.append(self.cleaned_data) + if hasattr(self, 'data'): + sources.append(self.data) + if hasattr(self, 'initial'): + sources.append(self.initial) + + for source in sources: + for key in candidate_keys: + if hasattr(source, 'get'): + value = source.get(key) + if value not in (None, ''): + field = self.fields.get(name) + if field is not None and not isinstance(value, (list, tuple, dict)): + try: + return field.to_python(value) + except Exception: + return value + return value + + if include_initial: + field = self.fields.get(name) + if field is not None and field.initial not in (None, ''): + return field.initial + + return default + + def _selected_instrument_type(self): + return self._field_value( + 'instrument_type', + aliases=('c_1_instrument_type',), + default='SOAR_GHTS_REDCAM' + ) + + def _instrument_defaults(self): + return self.INSTRUMENT_DEFAULTS.get( + self._selected_instrument_type(), + self.INSTRUMENT_DEFAULTS['SOAR_GHTS_REDCAM'] + ) + def _selected_exposure_time(self): + return self._field_value( + 'exposure_time', + aliases=('c_1_ic_1_exposure_time',), + default=self._instrument_defaults()['exposure_time'], + include_initial=False + ) + + def _selected_exposure_count(self): + return self._field_value( + 'exposure_count', + aliases=('c_1_ic_1_exposure_count',), + default=self._instrument_defaults()['exposure_count'] + ) - def filter_choices(self): - return set([ - (f['code'], f['name']) for ins in self._get_instruments().values() for f in - ins['optical_elements'].get('slits', []) - ]) + def _selected_readout(self): + return self._field_value( + 'readout', + aliases=('c_1_ic_1_mode',), + default=self._instrument_defaults()['readout'] + ) - def grating_choices(self): - return set([ - (f['code'], f['name']) for ins in self._get_instruments().values() for f in - ins['optical_elements'].get('gratings', []) - ]) + @classmethod + def readout_choices(cls): + seen = set() + choices = [] + for instrument in cls.READOUT_CHOICES_BY_INSTRUMENT.values(): + for choice in instrument: + if choice[0] not in seen: + choices.append(choice) + seen.add(choice[0]) - def clean_start(self): - # Took care of this in clean method, so ignore - return self.cleaned_data['start'] + return choices + def _selected_rotator_angle(self): + return self._field_value( + 'rotator_angle', + aliases=('c_1_ic_1_rotator_angle',), + default=self._instrument_defaults()['rotator_angle'] + ) - def clean_end(self): - # Took care of this in clean method, so ignore - return self.cleaned_data['end'] + def _selected_max_airmass(self): + return self._field_value( + 'max_airmass', + aliases=('c_1_max_airmass',), + default=self._instrument_defaults()['max_airmass'] + ) + def _selected_min_lunar_distance(self): + return self._field_value( + 'min_lunar_distance', + aliases=('c_1_min_lunar_distance',), + default=20 + ) def clean(self): cleaned_data = super().clean() - target = Target.objects.get(pk=cleaned_data['target_id']) - cleaned_data['name'] = target.name - now = datetime.datetime.utcnow() - if cleaned_data.get('delay_start'): - cleaned_data['start'] = str(now + datetime.timedelta(days=cleaned_data['delay_amount'])) - cleaned_data['end'] = str(now + datetime.timedelta(days=cleaned_data['window']+cleaned_data['delay_amount'])) - else: - cleaned_data['start'] = str(now) - cleaned_data['end'] = str(now + datetime.timedelta(days=cleaned_data['window'])) - #cleaned_data['start'] = str(datetime.datetime.utcnow()) - #cleaned_data['end'] = str(datetime.datetime.utcnow() + - # datetime.timedelta(days=cleaned_data['window'])) + target_id = cleaned_data.get('target_id') + instrument_type = cleaned_data.get('instrument_type') or self._selected_instrument_type() + defaults = self.INSTRUMENT_DEFAULTS.get(instrument_type, self.INSTRUMENT_DEFAULTS['SOAR_GHTS_REDCAM']) + + if target_id: + try: + target = Target.objects.get(pk=target_id) + cleaned_data['name'] = target.name + except Target.DoesNotExist: + self.add_error(None, 'Selected target no longer exists.') + return cleaned_data + + max_airmass = cleaned_data.get('max_airmass') + min_lunar_distance = cleaned_data.get('min_lunar_distance') + exposure_time = cleaned_data.get('exposure_time') + exposure_count = cleaned_data.get('exposure_count') + readout = cleaned_data.get('readout') + + cleaned_data['observation_mode'] = 'NORMAL' + cleaned_data['max_airmass'] = defaults['max_airmass'] if max_airmass in (None, '') else max_airmass + cleaned_data['min_lunar_distance'] = 20 if min_lunar_distance in (None, '') else min_lunar_distance + cleaned_data['rotator_angle'] = defaults['rotator_angle'] + cleaned_data['exposure_time'] = defaults['exposure_time'] if exposure_time in (None, '') else exposure_time + cleaned_data['exposure_count'] = defaults['exposure_count'] if exposure_count in (None, '') else exposure_count + allowed_readouts = {choice[0] for choice in self.READOUT_CHOICES_BY_INSTRUMENT.get(instrument_type, [])} + cleaned_data['readout'] = readout if readout in allowed_readouts else defaults['readout'] + cleaned_data['grating'] = '' + cleaned_data['filter'] = '' return cleaned_data - - def _build_instrument_config(self): - instrument_configs = super()._build_instrument_config() - - instrument_configs[0]['optical_elements'] = { - 'slit': self.cleaned_data['filter'], - 'grating': self.cleaned_data['grating']#SPECTRAL_GRATING + def _constraints(self): + return { + 'max_airmass': self._selected_max_airmass(), + 'min_lunar_distance': self._selected_min_lunar_distance(), + 'max_lunar_phase': 1.0, + 'max_seeing': None, + 'min_transparency': None, + 'extra_params': {}, } - instrument_configs[0]['rotator_mode'] = 'SKY' - instrument_configs[0]['mode'] = self.cleaned_data['readout'] - return instrument_configs + def _guiding_config(self): + return { + 'optional': False, + 'mode': 'ON', + 'optical_elements': {}, + 'exposure_time': None, + 'extra_params': {}, + } + def _acquisition_config(self, mode): + return { + 'mode': mode, + 'exposure_time': None, + 'extra_params': {}, + } - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.fields['proposal'] = forms.ChoiceField(choices=self.proposal_choices(),initial='SOAR2022A-002') - self.fields['instrument_type'] = forms.ChoiceField(choices=self.instrument_choices(), initial='SOAR_GHTS_REDCAM', label='') - self.fields['grating'] = forms.ChoiceField(choices=self.grating_choices(), initial='400_SYGY', label='') - self.fields['filter'] = forms.ChoiceField(choices=list(self.filter_choices()), initial=list(self.filter_choices())[0][0], label='') - self.fields['readout'] = forms.ChoiceField(choices=self.mode_choices('readout'), initial='GHTS_R_400m1_2x2', label='') + def _science_instrument_config(self): + instrument_type = self._selected_instrument_type() + extra_params = {'offset_ra': 0, 'offset_dec': 0} + if instrument_type == 'SOAR_TRIPLESPEC': + extra_params['rotator_angle'] = self._selected_rotator_angle() + + return { + 'exposure_time': self._selected_exposure_time(), + 'exposure_count': self._selected_exposure_count(), + 'optical_elements': {}, + 'mode': self._selected_readout(), + 'rotator_mode': 'SKY', + 'extra_params': extra_params, + } + + def _arc_config(self, science_config, priority): + arc_config = copy.deepcopy(science_config) + arc_config['type'] = 'ARC' + arc_config['priority'] = priority + arc_config['instrument_configs'][0]['exposure_time'] = 0.5 + arc_config['instrument_configs'][0]['exposure_count'] = 1 + arc_config['instrument_configs'][0]['extra_params']['rotator_angle'] = self._selected_rotator_angle() + arc_config['acquisition_config'] = self._acquisition_config('OFF') + return arc_config + + def _standard_config(self, science_config): + standard_config = copy.deepcopy(science_config) + standard_config['type'] = 'STANDARD' + standard_config['priority'] = 2 + standard_config['instrument_configs'][0]['exposure_time'] = 65.0 + standard_config['instrument_configs'][0]['exposure_count'] = 1 + standard_config['instrument_configs'][0]['mode'] = 'fowler8_coadds1' + standard_config['instrument_configs'][0]['extra_params']['rotator_angle'] = self._selected_rotator_angle() + standard_config['acquisition_config'] = self._acquisition_config('OFF') + return standard_config + + def observation_payload(self): + payload = super().observation_payload() + request_group = payload['requests'][0] + request_group['configuration_repeats'] = 1 + request_group['optimization_type'] = 'TIME' + + for config in request_group.get('configurations', []): + target = config.get('target', {}) + if target.get('epoch') is None: + target['epoch'] = 2000.0 + if target.get('proper_motion_ra') is None: + target['proper_motion_ra'] = 0.0 + if target.get('proper_motion_dec') is None: + target['proper_motion_dec'] = 0.0 + if target.get('parallax') is None: + target['parallax'] = 0.0 + + science_config = request_group['configurations'][0] + instrument_type = self._selected_instrument_type() + science_config['type'] = 'SPECTRUM' + science_config['instrument_type'] = instrument_type + science_config['constraints'] = self._constraints() + science_config['instrument_configs'] = [self._science_instrument_config()] + science_config['acquisition_config'] = self._acquisition_config('MANUAL') + science_config['guiding_config'] = self._guiding_config() + science_config['priority'] = 1 if instrument_type == 'SOAR_TRIPLESPEC' else 2 + + if instrument_type == 'SOAR_TRIPLESPEC': + request_group['configurations'] = [ + science_config, + self._standard_config(science_config), + ] + else: + request_group['configurations'] = [ + self._arc_config(science_config, 1), + science_config, + self._arc_config(science_config, 3), + ] + + return payload + + def _configure_proposal_field(self): + proposal_choices = [ + choice + for choice in self.proposal_choices() + if "SOAR" in str(choice[0]) or "SOAR" in str(choice[1]) + ] + + if not proposal_choices: + proposal_choices = self.proposal_choices() + + self.fields["proposal"] = forms.ChoiceField( + choices=proposal_choices, + initial=proposal_choices[0][0] if proposal_choices else None, + ) + + def _configure_instrument_fields(self): + defaults = self.INSTRUMENT_DEFAULTS["SOAR_GHTS_REDCAM"] + + self.fields["instrument_type"] = forms.ChoiceField( + choices=self.instrument_choices(), + initial="SOAR_GHTS_REDCAM", + required=False, + label="Instrument", + ) - for field_name in ['start', 'end', 'name', 'groups']: - self.fields[field_name].widget = forms.HiddenInput() + self.fields["readout"].choices = self.readout_choices() + + self.fields["exposure_time"].initial = defaults["exposure_time"] + self.fields["exposure_count"].initial = defaults["exposure_count"] + self.fields["max_airmass"].initial = defaults["max_airmass"] + self.fields["min_lunar_distance"].initial = 20 + self.fields["readout"].initial = defaults["readout"] + self.fields["observation_mode"].initial = "NORMAL" + + self.fields["instrument_type"].widget.attrs.pop("required", None) + self.fields["instrument_type"].widget.is_required = False + + def _configure_hidden_fields(self): + for field_name in self.HIDDEN_FIELDS: + field = self.fields.get(field_name) + if field: + field.widget = forms.HiddenInput() + field.required = False + + if "groups" in self.fields: + self.fields["groups"].widget = forms.HiddenInput() + + def _configure_required_fields(self): + for field_name in ( + "name", + "exposure_time", + "exposure_count", + "max_airmass", + "min_lunar_distance", + "readout", + ): + self.fields[field_name].required = False + + def _configure_start_end_fields(self): + now = datetime.now() + self.fields['start'].initial = now + self.fields['end'].initial = now + timedelta(hours=24) + + def _configure_layout(self): + self.helper.render_unmentioned_fields = False + + hidden_layout = [ + field + for field in self.HIDDEN_FIELDS + if field in self.fields + ] + + if "groups" in self.fields: + hidden_layout.append("groups") self.helper.layout = Layout( - self.common_layout, + *hidden_layout, Div( - Div( - HTML("

"), - PrependedAppendedText( - 'window','Once in the next', 'days' + HTML('

One-time SOAR submission using the default portal setup for each instrument.

'), + Row( + Column('instrument_type', css_class='col-md-6'), + Column('readout', css_class='col-md-6'), + ), + Row( + Column( + 'start', + css_class='col' ), - Div( - Column('delay_start'), - Column(AppendedText('delay_amount', 'days')), - css_class='form_row' + Column( + 'end', + css_class='col' ), - PrependedText('exposure_time','Exposure Time'), - PrependedText('exposure_count','Exposure Count'), - PrependedText('rotator_angle','Rotator Angle'), - PrependedText('instrument_type','Camera'), - PrependedText('grating', 'Grating'), - PrependedText('filter','Slit Width'), - PrependedText('readout', 'Readout Mode'), - css_class='col' + css_class='form-row' + ), + Row( + Column(PrependedText('exposure_time', 'Exposure Time'), css_class='col-md-6'), + Column(PrependedText('exposure_count', 'Exposure Count'), css_class='col-md-6'), ), - Div( - HTML("

"), - PrependedText('max_airmass', 'Airmass <'), - PrependedText('min_lunar_distance', 'Lunar Distance >'), - PrependedText('ipp_value', 'IPP'), - 'proposal', - 'observation_mode', - css_class='col' + Row( + Column(PrependedText('max_airmass', '<'), css_class='col-md-6'), + Column(PrependedText('min_lunar_distance', '>'), css_class='col-md-6'), ), - css_class='form-row', + Row( + Column('ipp_value', css_class='col-md-6'), + Column('proposal', css_class='col-md-6'), + ), + css_class='col-12' ), self.button_layout() ) + def __init__(self, *args, **kwargs): + kwargs = self._prepare_form_kwargs(kwargs) + super().__init__(*args, **kwargs) + + if self.is_bound and hasattr(self.data, "copy"): + data = self.data.copy() + self._synchronize_primary_and_alias_data(data) + self.data = data -class SOARFacility(SOARFacility): + self._configure_proposal_field() + self._configure_instrument_fields() + self._configure_hidden_fields() + self._configure_required_fields() + self._configure_start_end_fields() + self._configure_layout() - observation_types = [('SPECTRA', 'Goodman Spectrograph RedCam: 1.0" slit'), - ('SPECTRA', 'Goodman Spectrograph BlueCam: 1.0" slit')] +class SOARFacility(BaseSOARFacility): + observation_types = [('SPECTRA', 'Spectra')] observation_forms = {'SPECTRA': SOARObservationForm} def get_form(self, observation_type): return SOARObservationForm - def add_calibrations(self, observation_payload): - _target = observation_payload['requests'][0]['configurations'][0]['target'] - _constraints = observation_payload['requests'][0]['configurations'][0]['constraints'] - instrument_type = observation_payload['requests'][0]['configurations'][0]['instrument_type'] - - if instrument_type == 'SOAR_TRIPLESPEC': - observation_payload['requests'][0]['configurations'][0]['instrument_configs'][0]['optical_elements'].pop('slit', '') - observation_payload['requests'][0]['configurations'][0]['instrument_configs'][0]['optical_elements'].pop('grating', '') - slit = '' - grating = '' - - else: - slit = observation_payload['requests'][0]['configurations'][0]['instrument_configs'][0]['optical_elements']['slit'] - grating = observation_payload['requests'][0]['configurations'][0]['instrument_configs'][0]['optical_elements']['grating'] - - rotator_angle = observation_payload['requests'][0]['configurations'][0]['instrument_configs'][0]['extra_params']['rotator_angle'] - readout = observation_payload['requests'][0]['configurations'][0]['instrument_configs'][0]['mode'] - - template_calibration= { - "instrument_type": instrument_type, - "instrument_configs": [{ - "exposure_count": 1, - "rotator_mode": "SKY", - "extra_params": { - "rotator_angle": rotator_angle - }, - 'optical_elements': { - 'slit': slit, - 'grating': grating - }, - 'mode': readout, - - }], - 'acquisition_config': { - "mode": "OFF" - }, - 'guiding_config': { - "mode": "ON", - }, - 'target': _target, - 'constraints': _constraints - } - - if instrument_type != 'SOAR_TRIPLESPEC': - arc = copy.deepcopy(template_calibration) - arc["type"] = "ARC" - arc["instrument_configs"][0]["exposure_time"] = 0.5 - observation_payload['requests'][0]['configurations'].append(arc) - - flat = copy.deepcopy(template_calibration) - flat["type"] = "LAMP_FLAT" - flat["instrument_configs"][0]["exposure_time"] = 2 - observation_payload['requests'][0]['configurations'].append(flat) - - return observation_payload - def validate_observation(self, observation_payload): - observation_payload = self.add_calibrations(observation_payload) response = make_request( 'POST', PORTAL_URL + '/api/requestgroups/validate/', @@ -239,11 +576,10 @@ def validate_observation(self, observation_payload): return response.json()['errors'] def submit_observation(self, observation_payload): - observation_payload = self.add_calibrations(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']] + return [r['id'] for r in response.json()['requests']] \ No newline at end of file diff --git a/custom_code/scheduling.py b/custom_code/scheduling.py index e8d84dd9..1f04ac0b 100644 --- a/custom_code/scheduling.py +++ b/custom_code/scheduling.py @@ -111,6 +111,7 @@ def cancel_observation(obs_group): pending_observations = obs_group.observation_records.filter(status='PENDING') logger.info(f'Found {pending_observations.count()} PENDING observation(s) in group {obs_group.id}') + dynamic_cadence = None try: dynamic_cadence = DynamicCadence.objects.get(observation_group=obs_group) logger.info(f'Current cadence status: {dynamic_cadence.active}') @@ -119,7 +120,6 @@ def cancel_observation(obs_group): logger.info(f'Dynamic Cadence turned off') except DynamicCadence.DoesNotExist: logger.warning(f"No dynamic cadence found for group {obs_group.id}") - return False all_canceled = True for obs_to_cancel in pending_observations: @@ -138,9 +138,10 @@ def cancel_observation(obs_group): all_canceled = False if not all_canceled: - logger.error(f'One or more cancellations failed, re-activating cadence') - dynamic_cadence.active = True - dynamic_cadence.save() + if dynamic_cadence is not None: + logger.error(f'One or more cancellations failed, re-activating cadence') + dynamic_cadence.active = True + dynamic_cadence.save() return False first_obs = obs_group.observation_records.order_by('created').first() diff --git a/custom_code/templates/custom_code/submit_lco_observations.html b/custom_code/templates/custom_code/submit_lco_observations.html index 6fc66667..a3e55009 100644 --- a/custom_code/templates/custom_code/submit_lco_observations.html +++ b/custom_code/templates/custom_code/submit_lco_observations.html @@ -40,8 +40,13 @@

Submit a Photometry Sequence

{% crispy phot_form %}
-

Submit a Spectroscopy Sequence

+

Submit a FLOYDS Sequence

{% crispy spec_form %} + {% if soar_form %} +
+

Submit a One-Time SOAR Spectrum

+ {% crispy soar_form %} + {% endif %}
\ No newline at end of file + diff --git a/custom_code/templatetags/custom_code_tags.py b/custom_code/templatetags/custom_code_tags.py index 3b7eaefd..672bd3be 100644 --- a/custom_code/templatetags/custom_code_tags.py +++ b/custom_code/templatetags/custom_code_tags.py @@ -34,12 +34,14 @@ from custom_code.forms import CustomDataProductUploadForm, PapersForm, PhotSchedulingForm, SpecSchedulingForm, ReferenceStatusForm, ThumbnailForm from tom_observations.utils import get_sidereal_visibility from custom_code.facilities.lco_facility import SnexPhotometricSequenceForm, SnexSpectroscopicSequenceForm +from custom_code.facilities.soar_facility import SOARObservationForm, user_can_access_soar from custom_code.thumbnails import make_thumb import base64 import logging import os logger = logging.getLogger(__name__) +TERMINAL_OBSERVING_STATES = {'COMPLETED', 'CANCELED', 'WINDOW_EXPIRED'} register = template.Library() @@ -378,7 +380,7 @@ def moon_vis(target): ) obj_pos = SkyCoord(target.ra, target.dec, unit=u.deg) - moon_pos = get_body("moon",times) + moon_pos = get_body("moon", times) separations = moon_pos.separation(obj_pos).deg phases = moon_illumination(times) @@ -649,8 +651,9 @@ def custom_upload_dataproduct(context, obj): return {'data_product_form': form} -@register.inclusion_tag('custom_code/submit_lco_observations.html') -def submit_lco_observations(target): +@register.inclusion_tag('custom_code/submit_lco_observations.html', takes_context=True) +def submit_lco_observations(context, target): + user = context.get('user') or getattr(context.get('request'), 'user', None) phot_initial = {'target_id': target.id, 'facility': 'LCO', 'observation_type': 'IMAGING', @@ -663,12 +666,25 @@ def submit_lco_observations(target): spec_form = SnexSpectroscopicSequenceForm(initial=spec_initial, auto_id='spec_%s') phot_form.helper.form_action = reverse('submit-lco-obs', kwargs={'facility': 'LCO'}) spec_form.helper.form_action = reverse('submit-lco-obs', kwargs={'facility': 'LCO'}) + + soar_form = None + if user_can_access_soar(user): + soar_initial = {'target_id': target.id, + 'facility': 'SOAR', + 'observation_type': 'SPECTRA', + 'name': get_best_name(target)} + soar_form = SOARObservationForm(initial=soar_initial, auto_id='soar_%s') + soar_form.helper.form_action = reverse('submit-lco-obs', kwargs={'facility': 'SOAR'}) + if not settings.TARGET_PERMISSIONS_ONLY: phot_form.fields['groups'].queryset = Group.objects.all() spec_form.fields['groups'].queryset = Group.objects.all() + if soar_form and 'groups' in soar_form.fields: + soar_form.fields['groups'].queryset = Group.objects.all() return {'object': target, 'phot_form': phot_form, - 'spec_form': spec_form} + 'spec_form': spec_form, + 'soar_form': soar_form} @register.inclusion_tag('custom_code/dash_lightcurve.html', takes_context=True) def dash_lightcurve(context, target, width, height): @@ -886,13 +902,14 @@ def custom_observation_plan(target, facility, length=1, interval=30, airmass_lim } def format_lco_summary(obs, group, is_active): - params = obs.parameters + params = obs.parameters or {} summary = [] + facility_name = params.get('facility') or obs.facility summary_dict = { 'observation': obs.id, 'group': group.id, - 'title': 'LCO Sequence', + 'title': 'SOAR Sequence' if facility_name == 'SOAR' else 'LCO Sequence', 'summary': '', 'comments': [] } @@ -931,8 +948,12 @@ def format_lco_summary(obs, group, is_active): elif obs_type == 'spectra': exp = params.get('exposure_time') + exp_count = params.get('exposure_count') if exp: - summary.append(f"{exp}s") + if exp_count: + summary.append(f"{exp}s x {exp_count}") + else: + summary.append(f"{exp}s") mode = params.get('observation_mode') if mode == 'TIME_CRITICAL': @@ -947,6 +968,9 @@ def format_lco_summary(obs, group, is_active): '2M0-SPECTRAL-AG': 'Spectral', '0M4-SCICAM-SBIG': 'SBIG', '0M4-SCICAM-QHY600': 'QHY', + 'SOAR_GHTS_REDCAM': 'Goodman RedCam', + 'SOAR_GHTS_BLUECAM': 'Goodman BlueCam', + 'SOAR_TRIPLESPEC': 'TripleSpec', } inst = instrument_dict.get(params.get('instrument_type')) @@ -1023,15 +1047,25 @@ def format_gemini_summary(obs, group): def call_facility_formats(obs, group, time_type): params = obs.parameters or {} - facility = params.get('facility', '') + facility = params.get('facility') or obs.facility or '' - if facility == 'LCO': + if facility in {'LCO', 'SOAR'}: return format_lco_summary(obs, group, time_type) elif facility == 'Gemini': return format_gemini_summary(obs, group) return None + +def _group_belongs_in_summary(group, is_active): + if group.dynamiccadence_set.exists(): + return group.dynamiccadence_set.filter(active=is_active).exists() + + has_non_terminal_records = group.observation_records.exclude( + status__in=TERMINAL_OBSERVING_STATES + ).exists() + return has_non_terminal_records if is_active else not has_non_terminal_records + @register.inclusion_tag('custom_code/observation_summary.html', takes_context=True) def observation_summary(context, target = None, is_active = False): user = context['request'].user @@ -1044,19 +1078,26 @@ def observation_summary(context, target = None, is_active = False): else: obs_records = ObservationRecord.objects.all() - obs_groups = ObservationGroup.objects.filter(observation_records__in=obs_records, dynamiccadence__active=is_active).distinct().order_by('-modified') + obs_groups = ObservationGroup.objects.filter( + observation_records__in=obs_records + ).distinct().order_by('-modified') parameters_summary = [] obs_records_active = [] content_type_obs_group = ContentType.objects.get_for_model(ObservationGroup) for group in obs_groups: + if not _group_belongs_in_summary(group, is_active): + continue + obs = group.observation_records.order_by('id').first() if not obs: continue - obs_records_active.append(obs) summary_dict = call_facility_formats(obs, group, is_active) + if summary_dict is None: + continue + obs_records_active.append(obs) comments = Comment.objects.filter(object_pk = group.id, content_type = content_type_obs_group) summary_dict['comments'] = [f"{c.user.first_name}: {c.comment}" for c in comments] diff --git a/custom_code/views.py b/custom_code/views.py index 49f6ac5c..d1735acb 100644 --- a/custom_code/views.py +++ b/custom_code/views.py @@ -59,6 +59,7 @@ from tom_targets.models import Target, TargetList, TargetName from tom_targets.templatetags.targets_extras import target_groups from tom_targets.views import TargetCreateView +from custom_code.facilities.soar_facility import user_can_access_soar from custom_code.filters import BrokerTargetFilter, CustomTargetFilter, TNSTargetFilter from custom_code.forms import CustomDataProductUploadForm, CustomTargetCreateForm, PapersForm, PhotSchedulingForm, ReferenceStatusForm, SNEx2RegistrationApprovalForm, SNEx2UserCreationForm, SpecSchedulingForm from custom_code.hooks import _get_tns_params, get_standards_from_snex1, get_unreduced_spectra @@ -946,6 +947,11 @@ def get_context_data(self, *args, **kwargs): class CustomObservationCreateView(ObservationCreateView): + def dispatch(self, request, *args, **kwargs): + if kwargs.get('facility') == 'SOAR' and not user_can_access_soar(request.user): + return HttpResponseForbidden('You do not have permission to submit SOAR observations.') + return super().dispatch(request, *args, **kwargs) + def get_form(self): """ Gets an instance of the form appropriate for the request. @@ -953,17 +959,44 @@ def get_form(self): :rtype: subclass of GenericObservationForm """ form = super().get_form() - if not settings.TARGET_PERMISSIONS_ONLY: + if not settings.TARGET_PERMISSIONS_ONLY and 'groups' in form.fields: form.fields['groups'].queryset = Group.objects.all() form.helper.form_action = reverse( - 'submit-lco-obs', kwargs={'facility': 'LCO'} + 'submit-lco-obs', kwargs={'facility': self.kwargs['facility']} ) return form def form_valid(self, form): form.cleaned_data['start_user'] = self.request.user.username + target = self.get_target() + existing_record_ids = set( + ObservationRecord.objects.filter(target=target).values_list('id', flat=True) + ) response = super().form_valid(form) + new_records = list( + ObservationRecord.objects.filter(target=target) + .exclude(id__in=existing_record_ids) + .order_by('created', 'id') + ) + obsgroup = None + + for record in new_records: + obsgroup = record.observationgroup_set.first() + if obsgroup: + break + + if self.kwargs.get('facility') == 'SOAR' and new_records and obsgroup is None: + obsgroup_name = form.cleaned_data.get('name') or f'{target.name} at {self.kwargs["facility"]}' + obsgroup = ObservationGroup.objects.create(name=obsgroup_name) + obsgroup.observation_records.add(*new_records) + assign_perm('tom_observations.view_observationgroup', self.request.user, obsgroup) + assign_perm('tom_observations.change_observationgroup', self.request.user, obsgroup) + assign_perm('tom_observations.delete_observationgroup', self.request.user, obsgroup) + for record in new_records: + assign_perm('tom_observations.view_observationrecord', self.request.user, record) + assign_perm('tom_observations.change_observationrecord', self.request.user, record) + assign_perm('tom_observations.delete_observationrecord', self.request.user, record) + if not settings.TARGET_PERMISSIONS_ONLY: - target = self.get_target() groups = form.cleaned_data.get('groups') if not groups: target_ct = ContentType.objects.get_for_model(Target) @@ -973,19 +1006,21 @@ def form_valid(self, form): ).values_list('group_id', flat=True).distinct() groups = Group.objects.filter(id__in=target_group_ids) if groups: - latest_record = ObservationRecord.objects.filter( - target=target - ).order_by('-created').first() - if latest_record: - obsgroup = latest_record.observationgroup_set.first() + if not new_records: + new_records = list( + ObservationRecord.objects.filter(target=target).order_by('-created')[:1] + ) + if obsgroup is None and new_records: + obsgroup = new_records[0].observationgroup_set.first() + for group in groups: if obsgroup: - for group in groups: - assign_perm('tom_observations.view_observationgroup', group, obsgroup) - assign_perm('tom_observations.change_observationgroup', group, obsgroup) - assign_perm('tom_observations.delete_observationgroup', group, obsgroup) - assign_perm('tom_observations.view_observationrecord', group, latest_record) - assign_perm('tom_observations.change_observationrecord', group, latest_record) - assign_perm('tom_observations.delete_observationrecord', group, latest_record) + assign_perm('tom_observations.view_observationgroup', group, obsgroup) + assign_perm('tom_observations.change_observationgroup', group, obsgroup) + assign_perm('tom_observations.delete_observationgroup', group, obsgroup) + for record in new_records: + assign_perm('tom_observations.view_observationrecord', group, record) + assign_perm('tom_observations.change_observationrecord', group, record) + assign_perm('tom_observations.delete_observationrecord', group, record) return response def make_tns_request_view(request): @@ -1148,7 +1183,7 @@ def load_observations_tab_view(request): observing_buttons_context = observing_buttons(target) previous_obs_context = custom_code_tags.observation_summary(context_dict, target, is_active = False) ongoing_obs_context = custom_code_tags.observation_summary(context_dict, target, is_active = True) - submit_obs_context = custom_code_tags.submit_lco_observations(target) + submit_obs_context = custom_code_tags.submit_lco_observations(context_dict, target) # Combine all contexts combined_context = { diff --git a/snex2/settings.py b/snex2/settings.py index eaec6292..4d960384 100644 --- a/snex2/settings.py +++ b/snex2/settings.py @@ -312,6 +312,7 @@ 'SOAR': { 'portal_url': 'https://observe.lco.global', 'api_key': os.environ.get('LCO_APIKEY', 'setyourapikey!'), + 'access_group_name': os.environ.get('SOAR_ACCESS_GROUP_NAME', 'PASSTA'), }, 'GEM': { 'portal_url': { @@ -406,8 +407,8 @@ 'custom_code.facilities.gemini_facility.GeminiFacility', #'tom_observations.facilities.gemini.GEMFacility', 'custom_code.facilities.lco_facility.SnexLCOFacility', - 'tom_observations.facilities.soar.SOARFacility', - #'custom_code.facilities.soar_facility.SOARFacility' + 'custom_code.facilities.soar_facility.SOARFacility', + #'tom_observations.facilities.soar.SOARFacility' ] TOM_HARVESTER_CLASSES = [ diff --git a/templates/tom_observations/observation_form.html b/templates/tom_observations/observation_form.html index 3b4d6549..bf81e684 100644 --- a/templates/tom_observations/observation_form.html +++ b/templates/tom_observations/observation_form.html @@ -42,64 +42,90 @@

Submit an observation to {{ form.facility.value }}

{% endblock %} diff --git a/templates/tom_observations/partials/observing_buttons.html b/templates/tom_observations/partials/observing_buttons.html index 4298a2a0..73bdb835 100644 --- a/templates/tom_observations/partials/observing_buttons.html +++ b/templates/tom_observations/partials/observing_buttons.html @@ -1,6 +1,14 @@ -{% for facility in facilities %} +{% for facility, facility_class in facilities.items %} {% if facility != 'LCO' %} -{{ facility }} Photometry -{{ facility }} Spectra + + {{ facility_class.get_button_label }} + {% endif %} {% endfor %}