Customizing an OCS Facility

The Observatory Control System (OCS) is an open-source software system whose goal is to bring the benefits of API-driven observing to the astronomical community. Las Cumbres Observatory successfully operates a global network of 20+ robotic telescopes using the OCS.

The OCS Facility module for the TOM Toolkit should work for an OCS based observatory by setting the proper settings in your TOM’s settings.py.

The base OCS Facility implementation supports showing all the instruments that are schedulable, along with all of their potential optical_elements as defined within your OCS configuration database.

 1   # settings.py
 2   TOM_FACILITY_CLASSES = [
 3       'tom_observations.facilities.ocs.OCSFacility',
 4       ...
 5   ]
 6   FACILITIES = {
 7       'OCS': {
 8           'portal_url': 'your-ocs-observation-portal-base-url',
 9           'api_key': 'your-ocs-account-api-token',
10           'archive_url': 'your-ocs-archive-api-base-url',
11           'max_configurations': 3,  # How many configurations are present on the form
12           'max_instrument_configs': 3  # How many instrument configurations are present on the form
13       },
14       ...
15   }

This should work for simple observatories with a single available instrument, but once you have multiple telescopes and instruments, you will likely want to subclass the OCS implementation and create your own facility. This will allow you to create specific form pages for each instrument you provide, which provides the opportunity to customize those forms for instrument specific features. We will walk you through an example, custom OCS Facility and observing form below. We also show several key ways in which they could be customized to fit your needs.

This guide assumes you have followed the getting started guide and have a working TOM up and running.

Create a new OCS based Observing Facility module

Many methods of customizing the TOM Toolkit involve inheriting/extending existing functionality. This time will be no different: you’ll crate a new observation module that inherits the existing functionality from tom_observations.facilities.ocs.OCSFacility.

First, create a python file somewhere in your project to house your new module. For example, it could live next to your settings.py, or if you’ve started a new app, it could live there. It doesn’t really matter, as long as it’s located somewhere in your project:

touch mytom/mytom/custom_ocs.py

Now add some code to this file to create a new observation module:

 1   # custom_ocs.py
 2   from tom_observations.facilities.ocs import OCSFacility
 3
 4
 5   class CustomOCSFacility(OCSFacility):
 6       name = 'CustomOCS'
 7       observation_forms = {
 8           'Instrument1': Instrument1ObservationForm,
 9           'Spectra': SpectraObservationForm
10       }

So what does the above code do?

  1. Line 2 imports the OCSFacility that is already shipped with the TOM Toolkit. You want this class because it contains functionality you will re-use in your own implementation.

  2. Line 5 defines a new class named CustomOCSFacility that inherits from OCSFacility.

  3. Line 6 sets the name attribute of this class to CustomOCS. This is how the TOM facilities modules knows how to reference your facility, and therefor should be unique.

  4. Line 7 defines two new Observation forms, which will be implemented in the next section.

Now you need to tell your TOM where to find your new module so you can use it to submit observations. Add (or edit) the following lines in your settings.py:

1   # settings.py
2   TOM_FACILITY_CLASSES = [
3       'mytom.custom_ocs.CustomOCSFacility',
4       ...
5   ]

This code lists all of the observation modules that should be available to your TOM.

With that done, go to any target in your TOM and you should see your new module in the list. But right now, if you click on your CustomOCS module, you will get an error because the specific forms you referenced do not exist yet. Those forms will be added the next two sections.

Create a new OCS based observing form for a specific instrument

Let’s assume your observatory has several instruments available, each with a varying set of extra parameters that can be set by the user. In this section, you will create a customized form specific to instrument Instrument1, and add some custom fields to its instrument_configuration layout. We will be adding a readout_mode dropdown since your instrument has many readout modes, and a defocus value, since your science requires setting how defocused the instrument should be for each exposure. First, start by subclassing the base class of the full OCS observation form: tom_observations.facilities.ocs.OCSFullObservationForm.

 1   # custom_ocs.py
 2   from tom_observations.facilities.ocs import OCSFullObservationForm, OCSFacility
 3   from django import forms
 4
 5
 6   class Instrument1InstrumentConfigLayout(OCSInstrumentConfigLayout):
 7       def get_final_ic_items(self, config_instance, instance):
 8           # This piece of layout will be added at the end of the base Instrument Config
 9           # Layout. There is also a method that could be overridden to add to the beginning,
10           # Or you can override _get_ic_layout to completely change the layout.
11           return (
12               Div(
13                   Div(
14                       f'c_{config_instance}_ic_{instance}_readout_mode',
15                       css_class='col'
16                   ),
17                   Div(
18                       f'c_{config_instance}_ic_{instance}_defocus',
19                       css_class='col'
20                   ),
21                   css_class='form-row'
22               )
23           )
24
25
26   class Instrument1ObservationForm(OCSFullObservationForm):
27       def __init__(self, *args, **kwargs):
28           super().__init__(*args, **kwargs)
29           # The init method is where you will define fields, since the field names are
30           # set based on the number of configurations and instrument configurations our
31           # form supports. You can also remove base fields here if you don't want them
32           # in your form.
33           for j in range(self.facility_settings.get_setting('max_configurations')):
34               for i in range(self.facility_settings.get_setting('max_instrument_configs')):
35                   self.fields[f'c_{j+1}_ic_{i+1}_defocus'] = forms.IntegerField(
36                       min_value=0, max_value=10, label='Defocus', initial=0, required=False,
37                       help_text='Defocus for instrument in mm')
38                   self.fields[f'c_{j+1}_ic_{i+1}_readout_mode'] = forms.ChoiceField(
39                       choices=self.filter_choices_for_group(oe_group_plural), required=False,
40                       label='Readout Mode')
41
42       def get_instruments(self):
43           # Override this method to filter down the set of instruments available
44           # This is used to define all other configuration fields as well, based on the
45           # instrument set available for this form.
46           instruments = super().get_instruments()
47           return {
48               code: instrument for (code, instrument) in instruments.items() if (
49                   'IMAGE' == instrument['type'] and 'INSTRUMENT1' == code.upper())
50           }
51
52       def configuration_type_choices(self):
53           # Override this method if you only want to expose a subset of the available
54           # configuration types to users.
55           return [('EXPOSE', 'Exposure'), ('REPEAT_EXPOSE', 'Exposure Sequence')]
56
57
58       def form_name(self):
59           # This must be a unique identifier for the form.
60           return 'Instrument1'
61
62       def instrument_config_layout_class(self):
63           # This method sets the Instrument Config Layout class. Here you are setting
64           # your custom class defined above which adds your two new fields to the form.
65           return Instrument1InstrumentConfigLayout
66
67       def _build_instrument_config(self, instrument_type, configuration_id, id):
68           # This is called when submitting or validating the form, and it constructs the
69           # payload to send to the OCS observation portal. You can get the payload with
70           # base fields and then add your new fields in here.
71           instrument_config = super()._build_instrument_config(instrument_type, configuration_id, id)
72           if self.cleaned_data.get(f'c_{j+1}_ic_{i+1}_readout_mode'):
73               instrument_config['mode'] = self.cleaned_data[f'c_{j+1}_ic_{i+1}_readout_mode']
74           if self.cleaned_data.get(f'c_{j+1}_ic_{i+1}_defocus'):
75               if 'extra_params' not in instrument_config:
76                   instrument_config['extra_params'] = {}
77               instrument_config['extra_params']['defocus'] = self.cleaned_data[f'c_{j+1}_ic_{i+1}_defocus']
78           return instrument_config

The above code should define a form which only has one specific instrument type, and adds two new fields to the instrument_config section of the form. Pay careful attention to the comments within the code block for a description of why each section is overriden.

Create a new OCS based observing form for spectrographs

Now let’s assume your observatory has multiple spectrographs, and each one has several different settings for acquisition. In this section, we will create another custom OCS observation form, this time tailoring it to spectrograph instruments and adding additional fields for acquisition parameters: acquisition mode, exposure_time and a guide_star. The guide star will be a target present in your TOM’s target database. You will start by subclassing the base class of the full OCS observation form: tom_observations.facilities.ocs.OCSFullObservationForm.

  1   # custom_ocs.py
  2   from tom_observations.facilities.ocs import OCSFullObservationForm, OCSFacility
  3   from django import forms
  4
  5
  6   class SpectrographConfigurationLayout(OCSConfigurationLayout):
  7       def get_initial_accordion_items(self, instance):
  8           # This piece of layout will be added at the beginning of the base Configuration Layout
  9           # accordion group. There is also a method that could be overridden to add to the end of the
 10           # accordion group, or you can override _get_config_layout to completely change the layout.
 11           return (
 12               Div(
 13                   Div(
 14                       f'c_{instance}_acquisition_mode',
 15                       css_class='col'
 16                   ),
 17                   Div(
 18                       f'c_{instance}_exposure_time',
 19                       css_class='col'
 20                   ),
 21                   css_class='form-row'
 22               ),
 23               Div(
 24                   Div(
 25                       f'c_{instance}_acquisition_guide_star',
 26                       css_class='col'
 27                   ),
 28                   css_class='form-row'
 29               )
 30           )
 31
 32           def get_final_ic_items(self, config_instance, instance):
 33           # This piece of layout will be added at the end of the base Instrument Config
 34           # Layout. There is also a method that could be overridden to add to the beginning,
 35           # Or you can override _get_ic_layout to completely change the layout.
 36           return (
 37               Div(
 38                   Div(
 39                       f'c_{config_instance}_ic_{instance}_readout_mode',
 40                       css_class='col'
 41                   ),
 42                   Div(
 43                       f'c_{config_instance}_ic_{instance}_defocus',
 44                       css_class='col'
 45                   ),
 46                   css_class='form-row'
 47               )
 48           )
 49
 50
 51   class SpectrographObservationForm(OCSFullObservationForm):
 52       def __init__(self, *args, **kwargs):
 53           super().__init__(*args, **kwargs)
 54           # Since you are adding fields to the acquisition mode, that is within the configuration
 55           for j in range(self.facility_settings.get_setting('max_configurations')):
 56               self.fields[f'c_{j+1}_acquisition_mode'] = forms.ChoiceField(
 57                   choices=self.mode_choices('acquisition', use_code_only=True), required=False, label='Acquisition Mode')
 58               self.fields[f'c_{j+1}_acquisition_exposure_time'] = forms.FloatField(
 59                   min_value=0.0,
 60                   help_text='Acquisition image exposure time',
 61                   label='Exposure Time', required=False
 62               )
 63               # This field leverages a helper method that gets a set of target choices from targets
 64               # in the same Target Group as your forms target.
 65               self.fields[f'c_{j+1}_acquisition_guide_star'] = forms.ChoiceField(
 66                   choices=(None, '') + self.target_group_choices(include_self=False),
 67                   required=False,
 68                   help_text='Set an acquisition guide star target. Must be in the same target group.',
 69                   label='Acquisition guide star target'
 70               )
 71
 72       def get_instruments(self):
 73           # Here only the instruments that are of type SPECTRA are returned.
 74           instruments = super().get_instruments()
 75           return {code: instrument for (code, instrument) in instruments.items() if ('SPECTRA' == instrument['type'])}
 76
 77
 78       def configuration_type_choices(self):
 79           # Here only the configuration types that you want users to submit with are Returned.
 80           # By default, all "Schedulable" configuration types will be available, as defined in configdb.
 81           return [
 82               ('SPECTRUM', 'Spectrum'),
 83               ('REPEAT_SPECTRUM', 'Spectrum Sequence'),
 84               ('ARC', 'Arc'),
 85               ('LAMP_FLAT', 'Lamp Flat')
 86           ]
 87
 88       def form_name(self):
 89           # This must be a unique identifier for the form.
 90           return 'spectrographs'
 91
 92       def configuration_layout_class(self):
 93           # This method sets the Configuration Layout class. Here you are setting your
 94           # custom class defined above which adds your new acquisition fields to the form.
 95           return SpectrographConfigurationLayout
 96
 97       def _build_acquisition_config(self, configuration_id):
 98           # This is called when submitting or validating the form, and it constructs the
 99           # acquisition config payload. Here we will add our extra fields into the payload
100           acquisition_config = super()._build_acquisition_config(configuration_id)
101           if self.cleaned_data.get(f'c_{configuration_id}_acquisition_mode'):
102               acquisition_config['mode'] = self.cleaned_data[f'c_{configuration_id}_acquisition_mode']
103           if self.cleaned_data.get(f'c_{configuration_id}_acquisition_exposure_time'):
104               acquisition_config['exposure_time'] = self.cleaned_data[f'c_{configuration_id}_acquisition_exposure_time']
105           if self.cleaned_data.get(f'c_{configuration_id}_acquisition_guide_star'):
106               target_details = self._build_target_fields(
107                   self.cleaned_data[f'c_{configuration_id}_acquisition_guide_star'], 0
108               )
109               if 'extra_params' not in acquisition_config:
110                   acquisition_config['extra_params'] = {}
111               acquisition_config['extra_params']['guide_star'] = target_details            {
112
113           return acquisition_config

The above code should define a form which only has spectrograph instruments, and adds three new fields to the acquisition_config section of the form.

Now that you have defined both new forms, your new OCS-based facility module should be complete! Try reloading your TOM and navigating to the details page for a specific Target. You should see your CustomOCS facility in the list, and clicking that should bring you to a page with the observation forms you’ve just defined.

Observation Utility Methods

In the examples above, you modified the _build_instrument_config() and _build_acquisition_config() methods to fill in those portions of the OCS request payload. The OCSFullObservationForm has a number of utility methods that can be overridden to change specific parts of the observation submission form. These can be reviewed here.

Custom OCS Settings

For a more complicated OCS based facility implementation, you may want to override the base OCSSettings and create your own facility settings class. This is necessary to hook in facility site locations for a visibility plot, and facility weather/availability information. To create your own custom settings class, start by subclassing OCSSettings like this:

 1   # custom_ocs.py
 2   from tom_observations.facilities.ocs import OCSFacility, OCSSettings
 3
 4
 5   class CustomOCSSettings(OCSSettings):
 6       # Place default values for your settings here, if you don't require users to enter them in their settings.py
 7       default_settings = {
 8           'portal_url': 'my-custom-ocs-observation-portal-url',
 9           'archive_url': 'my-custom-ocs-archive-api-url',
10           'api_key': '',
11           'max_instrument_configs': 5,
12           'max_configurations': 5
13       }
14
15       # This facility_name should be unique among your TOM facilities.
16       # This is where the code will look for settings for this facility,
17       # under FACILITIES -> facility_name in settings.py.
18       def __init__(self, facility_name='CustomOCS'):
19           super().__init__(facility_name=facility_name)
20
21       def get_fits_facility_header_value(self):
22           # Define what your custom facilities fits header value is in your data products
23           return 'MyFacility'
24
25       def get_sites(self):
26           # Return a dictionary of site names to site details here, used for visibility calculations.
27           return {
28               'My Site 1': {
29                   'sitecode': 'ms1',
30                   'latitude': -31.272,
31                   'longitude': 149.07,
32                   'elevation': 1116
33               },
34               'My Site 2': {
35                   'sitecode': 'ms2',
36                   'latitude': -32.38,
37                   'longitude': 20.81,
38                   'elevation': 1804
39               },
40           }
41
42       def get_weather_urls(self):
43           # Returns a dictionary of sites with weather urls for retrieving weather data for each site
44           return {
45               'code': self.facility_name,
46               'sites': [
47                   {
48                       'code': site['sitecode'],
49                       'weather_url': f'https://my-weather-url-base/?site={site["sitecode"]}'
50                   }
51                   for site in self.get_sites().values()]
52           }
53
54   class CustomOCSFacility(OCSFacility):
55       name = 'CustomOCS'
56       observation_forms = {
57           'Instrument1': Instrument1ObservationForm,
58           'Spectra': SpectraObservationForm
59       }
60
61       def __init__(self, facility_settings=CustomOCSSettings('CustomOCS')):
62           super().__init__(facility_settings=facility_settings)

Notice that the only change to the CustomOCSFacility was the overriding of the __init__() method to set the facility_settings class to be an instance of our newly created CustomOCSSettings class. Please review the base OCSSettings class to see what other behaviour can be customized, including certain fields help_text or certain archive data configuration information.