• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

TOMToolkit / tom_base / 6713509976

31 Oct 2023 11:13PM UTC coverage: 86.773% (+0.7%) from 86.072%
6713509976

push

github-actions

web-flow
Merge pull request #699 from TOMToolkit/dev

Multi-Feature Merge. Please Review Carefully.

795 of 795 new or added lines in 39 files covered. (100.0%)

8253 of 9511 relevant lines covered (86.77%)

0.87 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

81.96
/tom_observations/facilities/lco.py
1
from datetime import datetime, timedelta
1✔
2
import logging
1✔
3

4
from crispy_forms.bootstrap import AppendedText, PrependedText, AccordionGroup
1✔
5
from crispy_forms.layout import Column, Div, HTML, Layout, Row, MultiWidgetField, Fieldset
1✔
6
from dateutil.parser import parse
1✔
7
from django import forms
1✔
8
from django.conf import settings
1✔
9

10
from tom_observations.cadence import CadenceForm
1✔
11
from tom_observations.facilities.ocs import (OCSTemplateBaseForm, OCSFullObservationForm, OCSBaseObservationForm,
1✔
12
                                             OCSConfigurationLayout, OCSInstrumentConfigLayout, OCSSettings,
13
                                             OCSFacility)
14
from tom_observations.widgets import FilterField
1✔
15

16
logger = logging.getLogger(__name__)
1✔
17

18

19
class LCOSettings(OCSSettings):
1✔
20
    """ LCO Specific settings
21
    """
22
    default_settings = {
1✔
23
        'portal_url': 'https://observe.lco.global',
24
        'archive_url': 'https://archive-api.lco.global/',
25
        'api_key': '',
26
        'max_instrument_configs': 5,
27
        'max_configurations': 5
28
    }
29
    # These class variables describe default help text for a variety of OCS fields.
30
    # Override them as desired for a specific OCS implementation.
31
    end_help = """
1✔
32
        Try the
33
        <a href="https://lco.global/observatory/visibility/" target="_blank">
34
            Target Visibility Calculator.
35
        </a>
36
    """
37

38
    instrument_type_help = """
1✔
39
        <a href="https://lco.global/observatory/instruments/" target="_blank">
40
            More information about LCO instruments.
41
        </a>
42
    """
43

44
    max_airmass_help = """
1✔
45
        Advice on
46
        <a href="https://lco.global/documentation/airmass-limit" target="_blank">
47
            setting the airmass limit.
48
        </a>
49
    """
50

51
    exposure_time_help = """
1✔
52
        Try the
53
        <a href="https://exposure-time-calculator.lco.global/" target="_blank">
54
            online Exposure Time Calculator.
55
        </a>
56
    """
57

58
    fractional_ephemeris_rate_help = """
1✔
59
        <em>Fractional Ephemeris Rate.</em> Will track with target motion if left blank. <br/>
60
        <b><em>Caution:</em></b> Setting any value other than "1" will cause the target to slowly drift from the central
61
        pointing. This could result in the target leaving the field of view for rapid targets, and/or
62
        long observation blocks. <br/>
63
    """
64

65
    muscat_exposure_mode_help = """
1✔
66
        Synchronous syncs the start time of exposures on all 4 cameras while asynchronous takes
67
        exposures as quickly as possible on each camera.
68
    """
69

70
    repeat_duration_help = """
1✔
71
        The requested duration for this configuration to be repeated within.
72
        Only applicable to <em>* Sequence</em> configuration types.
73
    """
74

75
    static_cadencing_help = """
1✔
76
        <em>Static cadence parameters.</em> Leave blank if no cadencing is desired.
77
        For information on static cadencing with LCO,
78
        <a href="https://lco.global/documentation/" target="_blank">
79
            check the Observation Portal getting started guide, starting on page 27.
80
        </a>
81
    """
82

83
    def __init__(self, facility_name='LCO'):
1✔
84
        super().__init__(facility_name=facility_name)
1✔
85

86
    def get_fits_facility_header_value(self):
1✔
87
        """ Should return the expected value in the fits facility header for data from this facility
88
        """
89
        return 'LCOGT'
×
90

91
    def get_sites(self):
1✔
92
        return {
×
93
            'Siding Spring': {
94
                'sitecode': 'coj',
95
                'latitude': -31.272,
96
                'longitude': 149.07,
97
                'elevation': 1116
98
            },
99
            'Sutherland': {
100
                'sitecode': 'cpt',
101
                'latitude': -32.38,
102
                'longitude': 20.81,
103
                'elevation': 1804
104
            },
105
            'Teide': {
106
                'sitecode': 'tfn',
107
                'latitude': 20.3,
108
                'longitude': -16.511,
109
                'elevation': 2390
110
            },
111
            'Cerro Tololo': {
112
                'sitecode': 'lsc',
113
                'latitude': -30.167,
114
                'longitude': -70.804,
115
                'elevation': 2198
116
            },
117
            'McDonald': {
118
                'sitecode': 'elp',
119
                'latitude': 30.679,
120
                'longitude': -104.015,
121
                'elevation': 2027
122
            },
123
            'Haleakala': {
124
                'sitecode': 'ogg',
125
                'latitude': 20.706,
126
                'longitude': -156.258,
127
                'elevation': 3065
128
            }
129
        }
130

131
    def get_weather_urls(self):
1✔
132
        return {
×
133
            'code': self.facility_name,
134
            'sites': [
135
                {
136
                    'code': site['sitecode'],
137
                    'weather_url': f'https://weather.lco.global/#/{site["sitecode"]}'
138
                }
139
                for site in self.get_sites().values()]
140
        }
141

142

143
class LCOTemplateBaseForm(OCSTemplateBaseForm):
1✔
144
    def __init__(self, *args, **kwargs):
1✔
145
        if 'facility_settings' not in kwargs:
×
146
            kwargs['facility_settings'] = LCOSettings("LCO")
×
147
        super().__init__(*args, **kwargs)
×
148

149
    def all_optical_element_choices(self, use_code_only=False):
1✔
150
        return sorted(set([
×
151
            (f['code'], f['code'] if use_code_only else f['name']) for ins in self.get_instruments().values() for f in
152
            ins['optical_elements'].get('filters', []) + ins['optical_elements'].get('slits', []) if f['schedulable']
153
        ]), key=lambda filter_tuple: filter_tuple[1])
154

155

156
class LCOConfigurationLayout(OCSConfigurationLayout):
1✔
157
    def get_final_accordion_items(self, instance):
1✔
158
        """ Override in the subclasses to add items at the end of the accordion group
159
        """
160
        return AccordionGroup('Fractional Ephemeris Rate',
1✔
161
                              Div(
162
                                  HTML(f'''<br/><p>{self.facility_settings.fractional_ephemeris_rate_help}</p>''')
163
                              ),
164
                              Div(
165
                                  f'c_{instance}_fractional_ephemeris_rate',
166
                                  css_class='form-col'
167
                              )
168
                              )
169

170

171
class MuscatConfigurationLayout(LCOConfigurationLayout):
1✔
172
    def _get_target_override(self, instance):
1✔
173
        if instance == 1:
1✔
174
            return (
1✔
175
                Div(
176
                    f'c_{instance}_guide_mode',
177
                    css_class='form-row'
178
                )
179
            )
180
        else:
181
            return (
1✔
182
                Div(
183
                    Div(
184
                        f'c_{instance}_guide_mode',
185
                        css_class='col'
186
                    ),
187
                    Div(
188
                        f'c_{instance}_target_override',
189
                        css_class='col'
190
                    ),
191
                    css_class='form-row'
192
                )
193
            )
194

195

196
class SpectralConfigurationLayout(LCOConfigurationLayout):
1✔
197
    def _get_target_override(self, instance):
1✔
198
        if instance == 1:
1✔
199
            return (
1✔
200
                Div(
201
                    f'c_{instance}_acquisition_mode',
202
                    css_class='form-row'
203
                )
204
            )
205
        else:
206
            return (
1✔
207
                Div(
208
                    Div(
209
                        f'c_{instance}_acquisition_mode',
210
                        css_class='col'
211
                    ),
212
                    Div(
213
                        f'c_{instance}_target_override',
214
                        css_class='col'
215
                    ),
216
                    css_class='form-row'
217
                )
218
            )
219

220

221
class MuscatInstrumentConfigLayout(OCSInstrumentConfigLayout):
1✔
222
    def _get_ic_layout(self, config_instance, instance, oe_groups):
1✔
223
        return (
1✔
224
            Div(
225
                Div(
226
                    f'c_{config_instance}_ic_{instance}_exposure_mode',
227
                    css_class='col'
228
                ),
229
                Div(
230
                    f'c_{config_instance}_ic_{instance}_exposure_count',
231
                    css_class='col'
232
                ),
233
                css_class='form-row'
234
            ),
235
            Div(
236
                Div(
237
                    f'c_{config_instance}_ic_{instance}_readout_mode',
238
                    css_class='col'
239
                ),
240
                css_class='form-row'
241
            ),
242
            Fieldset("Exposure Times",
243
                     HTML('''<p>Select the exposure time for each channel.</p>'''),
244
                     Div(
245
                         Div(
246
                             f'c_{config_instance}_ic_{instance}_exposure_time_g',
247
                             css_class='col'
248
                         ),
249
                         Div(
250
                             f'c_{config_instance}_ic_{instance}_exposure_time_i',
251
                             css_class='col'
252
                         ),
253
                         css_class='form-row'
254
                     ),
255
                     Div(
256
                         Div(
257
                             f'c_{config_instance}_ic_{instance}_exposure_time_r',
258
                             css_class='col'
259
                         ),
260
                         Div(
261
                             f'c_{config_instance}_ic_{instance}_exposure_time_z',
262
                             css_class='col'
263
                         ),
264
                         css_class='form-row'
265
                     )
266
                     ),
267
            *self._get_oe_groups_layout(config_instance, instance, oe_groups)
268
        )
269

270

271
class SpectralInstrumentConfigLayout(OCSInstrumentConfigLayout):
1✔
272
    def _get_ic_layout(self, config_instance, instance, oe_groups):
1✔
273
        return (
1✔
274
            Div(
275
                Div(
276
                    f'c_{config_instance}_ic_{instance}_exposure_time',
277
                    css_class='col'
278
                ),
279
                Div(
280
                    f'c_{config_instance}_ic_{instance}_exposure_count',
281
                    css_class='col'
282
                ),
283
                css_class='form-row'
284
            ),
285
            Div(
286
                Div(
287
                    f'c_{config_instance}_ic_{instance}_rotator_mode',
288
                    css_class='col'
289
                ),
290
                Div(
291
                    f'c_{config_instance}_ic_{instance}_rotator_angle',
292
                    css_class='col'
293
                ),
294
                css_class='form-row'
295
            ),
296
            *self._get_oe_groups_layout(config_instance, instance, oe_groups)
297
        )
298

299

300
class LCOOldStyleObservationForm(OCSBaseObservationForm):
1✔
301
    """
302
    The LCOOldStyleObservationForm provides the backwards compatibility for the Imaging and Spectral Sequence
303
    forms to remain the same as they were previously despite the upgrades to the other LCO forms.
304
    """
305
    exposure_count = forms.IntegerField(min_value=1)
1✔
306
    min_lunar_distance = forms.IntegerField(min_value=0, label='Minimum Lunar Distance', required=False)
1✔
307
    fractional_ephemeris_rate = forms.FloatField(min_value=0.0, max_value=1.0,
1✔
308
                                                 label='Fractional Ephemeris Rate',
309
                                                 help_text='Value between 0 (Sidereal Tracking) '
310
                                                           'and 1 (Target Tracking). If blank, Target Tracking.',
311
                                                 required=False)
312

313
    def __init__(self, *args, **kwargs):
1✔
314
        if 'facility_settings' not in kwargs:
1✔
315
            kwargs['facility_settings'] = LCOSettings("LCO")
1✔
316
        super().__init__(*args, **kwargs)
1✔
317
        self.fields['exposure_time'] = forms.FloatField(
1✔
318
            min_value=0.1, widget=forms.TextInput(attrs={'placeholder': 'Seconds'}),
319
            help_text=self.facility_settings.exposure_time_help
320
        )
321
        self.fields['max_airmass'] = forms.FloatField(help_text=self.facility_settings.max_airmass_help, min_value=0)
1✔
322
        self.fields['max_lunar_phase'] = forms.FloatField(
1✔
323
            help_text=self.facility_settings.max_lunar_phase_help, min_value=0,
324
            max_value=1.0, label='Maximum Lunar Phase', required=False
325
        )
326
        self.fields['filter'] = forms.ChoiceField(choices=self.all_optical_element_choices())
1✔
327
        self.fields['instrument_type'] = forms.ChoiceField(choices=self.instrument_choices())
1✔
328

329
        if isinstance(self, CadenceForm):
1✔
330
            self.helper.layout.insert(2, self.cadence_layout())
×
331

332
    def layout(self):
1✔
333
        return Div(
1✔
334
            Div(
335
                Div(
336
                    'name',
337
                    css_class='col'
338
                ),
339
                Div(
340
                    'proposal',
341
                    css_class='col'
342
                ),
343
                css_class='form-row'
344
            ),
345
            Div(
346
                Div(
347
                    'observation_mode',
348
                    css_class='col'
349
                ),
350
                Div(
351
                    'ipp_value',
352
                    css_class='col'
353
                ),
354
                css_class='form-row'
355
            ),
356
            Div(
357
                Div(
358
                    'optimization_type',
359
                    css_class='col'
360
                ),
361
                Div(
362
                    'configuration_repeats',
363
                    css_class='col'
364
                ),
365
                css_class='form-row'
366
            ),
367
            Div(
368
                Div(
369
                    'start',
370
                    css_class='col'
371
                ),
372
                Div(
373
                    'end',
374
                    css_class='col'
375
                ),
376
                css_class='form-row'
377
            ),
378
            Div(
379
                Div(
380
                    'exposure_count',
381
                    css_class='col'
382
                ),
383
                Div(
384
                    'exposure_time',
385
                    css_class='col'
386
                ),
387
                css_class='form-row'
388
            ),
389
            Div(
390
                Div(
391
                    'filter',
392
                    css_class='col'
393
                ),
394
                Div(
395
                    'max_airmass',
396
                    css_class='col'
397
                ),
398
                css_class='form-row'
399
            ),
400
            Div(
401
                Div(
402
                    'min_lunar_distance',
403
                    css_class='col'
404
                ),
405
                Div(
406
                    'max_lunar_phase',
407
                    css_class='col'
408
                ),
409
                css_class='form-row'
410
            )
411
        )
412

413
    def get_instruments(self):
1✔
414
        """Filter the instruments from the OCSBaseObservationForm.get_instruments()
415
        (i.e. the super class) in an LCO-specifc way.
416
        """
417
        instruments = super().get_instruments()
1✔
418
        filtered_instruments = {
1✔
419
            code: instrument
420
            for (code, instrument) in instruments.items()
421
            if (instrument['type'] in ['IMAGE', 'SPECTRA'] and
422
                ('MUSCAT' not in code and 'SOAR' not in code))
423
        }
424
        return filtered_instruments
1✔
425

426
    def all_optical_element_choices(self, use_code_only=False):
1✔
427
        return sorted(set([
×
428
            (f['code'], f['code'] if use_code_only else f['name']) for ins in self.get_instruments().values() for f in
429
            ins['optical_elements'].get('filters', []) + ins['optical_elements'].get('slits', []) if f['schedulable']
430
        ]), key=lambda filter_tuple: filter_tuple[1])
431

432
    def _build_target_extra_params(self, configuration_id=1):
1✔
433
        # if a fractional_ephemeris_rate has been specified, add it as an extra_param
434
        # to the target_fields
435
        if 'fractional_ephemeris_rate' in self.cleaned_data:
1✔
436
            return {'fractional_ephemeris_rate': self.cleaned_data['fractional_ephemeris_rate']}
1✔
437
        return {}
×
438

439
    def _build_instrument_configs(self):
1✔
440
        instrument_config = {
1✔
441
            'exposure_count': self.cleaned_data['exposure_count'],
442
            'exposure_time': self.cleaned_data['exposure_time'],
443
            'optical_elements': {
444
                'filter': self.cleaned_data['filter']
445
            }
446
        }
447
        instrument_configs = [instrument_config]
1✔
448
        return instrument_configs
1✔
449

450

451
class LCOFullObservationForm(OCSFullObservationForm):
1✔
452
    def __init__(self, *args, **kwargs):
1✔
453
        if 'facility_settings' not in kwargs:
1✔
454
            kwargs['facility_settings'] = LCOSettings("LCO")
1✔
455
        if 'data' in kwargs:
1✔
456
            # convert data argument field names to the proper fields. Data is assumed to be observation payload format
457
            kwargs['data'] = self.convert_old_observation_payload_to_fields(kwargs['data'])
1✔
458
        super().__init__(*args, **kwargs)
1✔
459
        for j in range(self.facility_settings.get_setting('max_configurations')):
1✔
460
            self.fields[f'c_{j+1}_fractional_ephemeris_rate'] = forms.FloatField(
1✔
461
                min_value=0.0, max_value=1.0, label='Fractional Ephemeris Rate',
462
                help_text='Value between 0 (Sidereal Tracking) '
463
                'and 1 (Target Tracking). If blank, Target Tracking.',
464
                required=False
465
            )
466
        # self.helper.layout = Layout(
467
        #     self.common_layout,
468
        #     self.layout(),
469
        #     self.button_layout()
470
        # )
471
        # if isinstance(self, CadenceForm):
472
        #     self.helper.layout.insert(2, self.cadence_layout())
473

474
    def convert_old_observation_payload_to_fields(self, data):
1✔
475
        """ This is a backwards compatibility function to allow us to load old-format observation parameters
476
            for existing ObservationRecords, which use the old form, but may still need to use the new form
477
            to submit cadence strategy observations.
478
        """
479
        if 'instrument_type' in data:
1✔
480
            data['c_1_instrument_type'] = data['instrument_type']
1✔
481
            del data['instrument_type']
1✔
482
        if 'max_airmass' in data:
1✔
483
            data['c_1_max_airmass'] = data['max_airmass']
1✔
484
            del data['max_airmass']
1✔
485
        if 'min_lunar_distance' in data:
1✔
486
            data['c_1_min_lunar_distance'] = data['min_lunar_distance']
×
487
            del data['min_lunar_distance']
×
488
        if 'fractional_ephemeris_rate' in data:
1✔
489
            data['c_1_fractional_ephemeris_rate'] = data['fractional_ephemeris_rate']
×
490
            del data['fractional_ephemeris_rate']
×
491
        if 'exposure_count' in data:
1✔
492
            data['c_1_ic_1_exposure_count'] = data['exposure_count']
1✔
493
            del data['exposure_count']
1✔
494
        if 'exposure_time' in data:
1✔
495
            data['c_1_ic_1_exposure_time'] = data['exposure_time']
1✔
496
            del data['exposure_time']
1✔
497
        if 'filter' in data:
1✔
498
            data['c_1_ic_1_filter'] = data['filter']
1✔
499
            del data['filter']
1✔
500
        return data
1✔
501

502
    def configuration_layout_class(self):
1✔
503
        return LCOConfigurationLayout
1✔
504

505
    def _build_target_extra_params(self, configuration_id=1):
1✔
506
        # if a fractional_ephemeris_rate has been specified, add it as an extra_param
507
        # to the target_fields
508
        if f'c_{configuration_id}_fractional_ephemeris_rate' in self.cleaned_data:
×
509
            return {'fractional_ephemeris_rate': self.cleaned_data[f'c_{configuration_id}_fractional_ephemeris_rate']}
×
510
        return {}
×
511

512

513
class LCOImagingObservationForm(LCOFullObservationForm):
1✔
514
    """
515
    The LCOImagingObservationForm allows the selection of parameters for observing using LCO's Imagers. The list of
516
    Imagers and their details can be found here: https://lco.global/observatory/instruments/
517
    """
518

519
    def get_instruments(self):
1✔
520
        instruments = super().get_instruments()
1✔
521
        return {
1✔
522
            code: instrument for (code, instrument) in instruments.items() if (
523
                'IMAGE' == instrument['type'] and 'MUSCAT' not in code and 'SOAR' not in code)
524
        }
525

526
    def form_name(self):
1✔
527
        return 'image'
1✔
528

529
    def configuration_type_choices(self):
1✔
530
        return [('EXPOSE', 'Exposure'), ('REPEAT_EXPOSE', 'Exposure Sequence')]
1✔
531

532

533
class LCOMuscatImagingObservationForm(LCOFullObservationForm):
1✔
534
    """
535
    The LCOMuscatImagingObservationForm allows the selection of parameter for observing using LCO's Muscat imaging
536
    instrument. More information can be found here: https://lco.global/observatory/instruments/muscat3/
537
    """
538

539
    def __init__(self, *args, **kwargs):
1✔
540
        if 'facility_settings' not in kwargs:
1✔
541
            kwargs['facility_settings'] = LCOSettings("LCO")
1✔
542
        super().__init__(*args, **kwargs)
1✔
543
        # Need to add the muscat specific exposure time fields to this form
544
        for j in range(self.facility_settings.get_setting('max_configurations')):
1✔
545
            self.fields[f'c_{j+1}_guide_mode'] = forms.ChoiceField(
1✔
546
                choices=self.mode_choices('guiding'), required=False, label='Guide Mode')
547
            for i in range(self.facility_settings.get_setting('max_instrument_configs')):
1✔
548
                self.fields.pop(f'c_{j+1}_ic_{i+1}_exposure_time', None)
1✔
549
                self.fields[f'c_{j+1}_ic_{i+1}_exposure_time_g'] = forms.FloatField(
1✔
550
                    min_value=0.0, label='Exposure Time g',
551
                    widget=forms.TextInput(attrs={'placeholder': 'Seconds'}), required=False)
552
                self.fields[f'c_{j+1}_ic_{i+1}_exposure_time_r'] = forms.FloatField(
1✔
553
                    min_value=0.0, label='Exposure Time r',
554
                    widget=forms.TextInput(attrs={'placeholder': 'Seconds'}), required=False)
555
                self.fields[f'c_{j+1}_ic_{i+1}_exposure_time_i'] = forms.FloatField(
1✔
556
                    min_value=0.0, label='Exposure Time i',
557
                    widget=forms.TextInput(attrs={'placeholder': 'Seconds'}), required=False)
558
                self.fields[f'c_{j+1}_ic_{i+1}_exposure_time_z'] = forms.FloatField(
1✔
559
                    min_value=0.0, label='Exposure Time z',
560
                    widget=forms.TextInput(attrs={'placeholder': 'Seconds'}), required=False)
561
                self.fields[f'c_{j+1}_ic_{i+1}_readout_mode'] = forms.ChoiceField(
1✔
562
                    choices=self.mode_choices('readout'), required=False, label='Readout Mode')
563
                self.fields[f'c_{j+1}_ic_{i+1}_exposure_mode'] = forms.ChoiceField(
1✔
564
                    label='Exposure Mode', required=False,
565
                    choices=self.mode_choices('exposure'),
566
                    help_text=self.facility_settings.muscat_exposure_mode_help
567
                )
568

569
    def convert_old_observation_payload_to_fields(self, data):
1✔
570
        data = super().convert_old_observation_payload_to_fields(data)
×
571
        if not data:
×
572
            return None
×
573
        ic_fields = [
×
574
            'exposure_time_g', 'exposure_time_r', 'exposure_time_i', 'exposure_time_z', 'exposure_mode',
575
            'diffuser_g_position', 'diffuser_r_position', 'diffuser_i_position', 'diffuser_z_position'
576
        ]
577
        for field in ic_fields:
×
578
            if field in data:
×
579
                data[f'c_1_ic_1_{field}'] = data[field]
×
580
                del data[field]
×
581

582
        if 'guider_mode' in data:
×
583
            data['c_1_guide_mode'] = data['guider_mode']
×
584
            del data['guider_mode']
×
585
        return data
×
586

587
    def form_name(self):
1✔
588
        return 'muscat'
1✔
589

590
    def instrument_config_layout_class(self):
1✔
591
        return MuscatInstrumentConfigLayout
1✔
592

593
    def configuration_layout_class(self):
1✔
594
        return MuscatConfigurationLayout
1✔
595

596
    def get_instruments(self):
1✔
597
        instruments = super().get_instruments()
1✔
598
        return {
1✔
599
            code: instrument for (code, instrument) in instruments.items() if (
600
                'IMAGE' == instrument['type'] and 'MUSCAT' in code)
601
        }
602

603
    def configuration_type_choices(self):
1✔
604
        return [('EXPOSE', 'Exposure'), ('REPEAT_EXPOSE', 'Exposure Sequence')]
1✔
605

606
    def _build_guiding_config(self, configuration_id=1):
1✔
607
        guiding_config = super()._build_guiding_config()
1✔
608
        guiding_config['mode'] = self.cleaned_data[f'c_{configuration_id}_guide_mode']
1✔
609
        # Muscat guiding `optional` setting only makes sense set to true from the telescope software perspective
610
        guiding_config['optional'] = True
1✔
611
        return guiding_config
1✔
612

613
    def _build_instrument_config(self, instrument_type, configuration_id, instrument_config_id):
1✔
614
        # Refer to the 'MUSCAT instrument configuration' section on this page: https://developers.lco.global/
615
        if not (self.cleaned_data.get(f'c_{configuration_id}_ic_{instrument_config_id}_exposure_time_g') and
1✔
616
                self.cleaned_data.get(f'c_{configuration_id}_ic_{instrument_config_id}_exposure_time_r') and
617
                self.cleaned_data.get(f'c_{configuration_id}_ic_{instrument_config_id}_exposure_time_i') and
618
                self.cleaned_data.get(f'c_{configuration_id}_ic_{instrument_config_id}_exposure_time_z')):
619
            return None
×
620
        instrument_config = {
1✔
621
            'exposure_count': self.cleaned_data[f'c_{configuration_id}_ic_{instrument_config_id}_exposure_count'],
622
            'exposure_time': max(
623
                self.cleaned_data[f'c_{configuration_id}_ic_{instrument_config_id}_exposure_time_g'],
624
                self.cleaned_data[f'c_{configuration_id}_ic_{instrument_config_id}_exposure_time_r'],
625
                self.cleaned_data[f'c_{configuration_id}_ic_{instrument_config_id}_exposure_time_i'],
626
                self.cleaned_data[f'c_{configuration_id}_ic_{instrument_config_id}_exposure_time_z']
627
            ),
628
            'optical_elements': {},
629
            'mode': self.cleaned_data[f'c_{configuration_id}_ic_{instrument_config_id}_readout_mode'],
630
            'extra_params': {
631
                'exposure_mode': self.cleaned_data[f'c_{configuration_id}_ic_{instrument_config_id}_exposure_mode'],
632
                'exposure_time_g': self.cleaned_data[f'c_{configuration_id}_ic_{instrument_config_id}_exposure_time_g'],
633
                'exposure_time_r': self.cleaned_data[f'c_{configuration_id}_ic_{instrument_config_id}_exposure_time_r'],
634
                'exposure_time_i': self.cleaned_data[f'c_{configuration_id}_ic_{instrument_config_id}_exposure_time_i'],
635
                'exposure_time_z': self.cleaned_data[f'c_{configuration_id}_ic_{instrument_config_id}_exposure_time_z'],
636
            }
637
        }
638
        for oe_group in self.get_optical_element_groups():
1✔
639
            instrument_config['optical_elements'][oe_group] = self.cleaned_data.get(
1✔
640
                f'c_{configuration_id}_ic_{instrument_config_id}_{oe_group}')
641

642
        return instrument_config
1✔
643

644

645
class LCOSpectroscopyObservationForm(LCOFullObservationForm):
1✔
646
    """
647
    The LCOSpectroscopyObservationForm allows the selection of parameters for observing using LCO's Spectrographs. The
648
    list of spectrographs and their details can be found here: https://lco.global/observatory/instruments/
649
    """
650

651
    def __init__(self, *args, **kwargs):
1✔
652
        if 'facility_settings' not in kwargs:
1✔
653
            kwargs['facility_settings'] = LCOSettings("LCO")
1✔
654
        super().__init__(*args, **kwargs)
1✔
655
        for j in range(self.facility_settings.get_setting('max_configurations')):
1✔
656
            self.fields[f'c_{j+1}_acquisition_mode'] = forms.ChoiceField(
1✔
657
                choices=self.mode_choices('acquisition', use_code_only=True), required=False, label='Acquisition Mode')
658
            for i in range(self.facility_settings.get_setting('max_instrument_configs')):
1✔
659
                self.fields[f'c_{j+1}_ic_{i+1}_rotator_mode'] = forms.ChoiceField(
1✔
660
                    choices=self.mode_choices('rotator'), label='Rotator Mode', required=False,
661
                    help_text='Only for Floyds')
662
                self.fields[f'c_{j+1}_ic_{i+1}_rotator_angle'] = forms.FloatField(
1✔
663
                    min_value=0.0, initial=0.0,
664
                    help_text='Rotation angle of slit. Only for Floyds `Slit Position Angle` rotator mode',
665
                    label='Rotator Angle', required=False
666
                )
667
                # Add None option and help text for SOAR Gratings
668
                if self.fields.get(f'c_{j+1}_ic_{i+1}_grating', None):
1✔
669
                    self.fields[f'c_{j+1}_ic_{i+1}_grating'].help_text = 'Only for SOAR'
1✔
670
                    self.fields[f'c_{j+1}_ic_{i+1}_grating'].choices.insert(0, ('None', 'None'))
1✔
671
                self.fields[f'c_{j+1}_ic_{i+1}_slit'].help_text = 'Only for Floyds'
1✔
672

673
    def convert_old_observation_payload_to_fields(self, data):
1✔
674
        data = super().convert_old_observation_payload_to_fields(data)
×
675
        if not data:
×
676
            return None
×
677
        if 'rotator_angle' in data:
×
678
            data['c_1_ic_1_rotator_angle'] = data['rotator_angle']
×
679
            if data['rotator_angle']:
×
680
                data['c_1_ic_1_rotator_mode'] = 'SKY'
×
681
            del data['rotator_angle']
×
682
        if 'c_1_ic_1_filter' in data:
×
683
            data['c_1_ic_1_slit'] = data['c_1_ic_1_filter']
×
684
            del data['c_1_ic_1_filter']
×
685

686
        return data
×
687

688
    def get_instruments(self):
1✔
689
        instruments = super().get_instruments()
1✔
690
        return {code: instrument for (code, instrument) in instruments.items() if ('SPECTRA' == instrument['type'])}
1✔
691

692
    def form_name(self):
1✔
693
        return 'spectra'
1✔
694

695
    def instrument_config_layout_class(self):
1✔
696
        return SpectralInstrumentConfigLayout
1✔
697

698
    def configuration_layout_class(self):
1✔
699
        return SpectralConfigurationLayout
1✔
700

701
    def configuration_type_choices(self):
1✔
702
        return [
1✔
703
            ('SPECTRUM', 'Spectrum'),
704
            ('REPEAT_SPECTRUM', 'Spectrum Sequence'),
705
            ('ARC', 'Arc'),
706
            ('LAMP_FLAT', 'Lamp Flat')
707
        ]
708

709
    def _build_acquisition_config(self, configuration_id=1):
1✔
710
        acquisition_config = {'mode': self.cleaned_data[f'c_{configuration_id}_acquisition_mode']}
×
711

712
        return acquisition_config
×
713

714
    def _build_configuration(self, build_id):
1✔
715
        configuration = super()._build_configuration(build_id)
×
716
        if not configuration:
×
717
            return None
×
718
        # If NRES, adjust the configuration types to match nres types
719
        if 'NRES' in configuration['instrument_type'].upper():
×
720
            if configuration['type'] == 'SPECTRUM':
×
721
                configuration['type'] = 'NRES_SPECTRUM'
×
722
            elif configuration['type'] == 'REPEAT_SPECTRUM':
×
723
                configuration['type'] = 'REPEAT_NRES_SPECTRUM'
×
724

725
        return configuration
×
726

727
    def _build_instrument_config(self, instrument_type, configuration_id, instrument_config_id):
1✔
728
        instrument_config = super()._build_instrument_config(instrument_type, configuration_id, instrument_config_id)
1✔
729
        if not instrument_config:
1✔
730
            return None
×
731
        # If floyds, add the rotator mode and angle in
732
        if 'FLOYDS' in instrument_type.upper() or 'SOAR' in instrument_type.upper():
1✔
733
            instrument_config['rotator_mode'] = self.cleaned_data[
1✔
734
                f'c_{configuration_id}_ic_{instrument_config_id}_rotator_mode'
735
                ]
736
            if instrument_config['rotator_mode'] == 'SKY':
1✔
737
                instrument_config['extra_params'] = {'rotator_angle': self.cleaned_data.get(
1✔
738
                    f'c_{configuration_id}_ic_{instrument_config_id}_rotator_angle', 0)}
739
            if 'FLOYDS' in instrument_type.upper():
1✔
740
                # Remove grating from FLOYDS requests
741
                instrument_config['optical_elements'].pop('grating', None)
1✔
742
        # Clear out the optical elements for NRES
743
        elif 'NRES' in instrument_type.upper():
1✔
744
            instrument_config['optical_elements'] = {}
1✔
745

746
        return instrument_config
1✔
747

748

749
class LCOPhotometricSequenceForm(LCOOldStyleObservationForm):
1✔
750
    """
751
    The LCOPhotometricSequenceForm provides a form offering a subset of the parameters in the LCOImagingObservationForm.
752
    The form is modeled after the Supernova Exchange application's Photometric Sequence Request Form, and allows the
753
    configuration of multiple filters, as well as a more intuitive proactive cadence form.
754
    """
755
    valid_instruments = ['1M0-SCICAM-SINISTRO', '0M4-SCICAM-SBIG', '2M0-SPECTRAL-AG']
1✔
756
    valid_filters = ['U', 'B', 'V', 'R', 'I', 'up', 'gp', 'rp', 'ip', 'zs', 'w']
1✔
757
    cadence_frequency = forms.IntegerField(required=True, help_text='in hours')
1✔
758

759
    def __init__(self, *args, **kwargs):
1✔
760
        if 'facility_settings' not in kwargs:
1✔
761
            kwargs['facility_settings'] = LCOSettings("LCO")
1✔
762
        if 'initial' in kwargs:
1✔
763
            # Because we use a FilterField custom field here that combines three fields, we must
764
            # convert those fields when they are passed in so validation doesn't depopulate the fields
765
            kwargs['initial'] = self.convert_filter_fields(kwargs['initial'])
×
766
        super().__init__(*args, **kwargs)
1✔
767

768
        # Add fields for each available filter as specified in the filters property
769
        for filter_code, filter_name in self.all_optical_element_choices():
1✔
770
            self.fields[filter_code] = FilterField(label=filter_name, required=False)
1✔
771

772
        # Massage cadence form to be SNEx-styled
773
        self.fields['cadence_strategy'] = forms.ChoiceField(
1✔
774
            choices=[('', 'Once in the next'), ('ResumeCadenceAfterFailureStrategy', 'Repeating every')],
775
            required=False,
776
        )
777
        for field_name in ['exposure_time', 'exposure_count', 'filter']:
1✔
778
            self.fields.pop(field_name)
1✔
779
        if self.fields.get('groups'):
1✔
780
            self.fields['groups'].label = 'Data granted to'
×
781
        for field_name in ['start', 'end']:
1✔
782
            self.fields[field_name].widget = forms.HiddenInput()
1✔
783
            self.fields[field_name].required = False
1✔
784

785
        self.helper.layout = Layout(
1✔
786
            Row(
787
                Column('name'),
788
                Column('cadence_strategy'),
789
                Column('cadence_frequency'),
790
            ),
791
            Layout('facility', 'target_id', 'observation_type'),
792
            self.layout(),
793
            self.button_layout()
794
        )
795

796
    def _build_instrument_configs(self):
1✔
797
        """
798
        Because the photometric sequence form provides form inputs for 10 different filters, they must be
799
        constructed into a list of instrument configurations as per the LCO API. This method constructs the
800
        instrument configurations in the appropriate manner.
801
        """
802
        instrument_configs = []
1✔
803
        for filter_name in self.valid_filters:
1✔
804
            if len(self.cleaned_data[filter_name]) > 0:
1✔
805
                instrument_configs.append({
1✔
806
                    'exposure_count': self.cleaned_data[filter_name][1],
807
                    'exposure_time': self.cleaned_data[filter_name][0],
808
                    'optical_elements': {
809
                        'filter': filter_name
810
                    }
811
                })
812

813
        return instrument_configs
1✔
814

815
    def convert_filter_fields(self, initial):
1✔
816
        if not initial:
×
817
            return initial
×
818
        for filter_name in self.valid_filters:
×
819
            if f'{filter_name}_0' in initial or f'{filter_name}_1' in initial or f'{filter_name}_2' in initial:
×
820
                initial[f'{filter_name}'] = [
×
821
                    initial[f'{filter_name}_0'],
822
                    initial[f'{filter_name}_1'],
823
                    initial[f'{filter_name}_2']
824
                ]
825
        return initial
×
826

827
    def clean_start(self):
1✔
828
        """
829
        Unless included in the submission, set the start time to now.
830
        """
831
        start = self.cleaned_data.get('start')
1✔
832
        if not start:  # Start is in cleaned_data as an empty string if it was not submitted, so check falsiness
1✔
833
            start = datetime.strftime(datetime.now(), '%Y-%m-%dT%H:%M:%S')
1✔
834
        return start
1✔
835

836
    def clean_end(self):
1✔
837
        """
838
        Override clean_end in order to avoid the superclass attempting to date-parse an empty string.
839
        """
840
        return self.cleaned_data.get('end')
1✔
841

842
    def clean(self):
1✔
843
        """
844
        This clean method does the following:
845
            - Adds an end time that corresponds with the cadence frequency
846
        """
847
        cleaned_data = super().clean()
1✔
848
        start = cleaned_data.get('start')
1✔
849
        cleaned_data['end'] = datetime.strftime(parse(start) + timedelta(hours=cleaned_data['cadence_frequency']),
1✔
850
                                                '%Y-%m-%dT%H:%M:%S')
851

852
        return cleaned_data
1✔
853

854
    def instrument_choices(self):
1✔
855
        """
856
        This method returns only the instrument choices available in the current SNEx photometric sequence form.
857
        """
858
        return sorted([(k, v['name'])
1✔
859
                       for k, v in self._get_instruments().items()
860
                       if k in LCOPhotometricSequenceForm.valid_instruments],
861
                      key=lambda inst: inst[1])
862

863
    def all_optical_element_choices(self, use_code_only=False):
1✔
864
        return sorted(set([
1✔
865
            (f['code'], f['name']) for ins in self._get_instruments().values() for f in
866
            ins['optical_elements'].get('filters', [])
867
            if f['code'] in LCOPhotometricSequenceForm.valid_filters]),
868
            key=lambda filter_tuple: filter_tuple[1])
869

870
    def cadence_layout(self):
1✔
871
        return Layout(
×
872
            Row(
873
                Column('cadence_type'), Column('cadence_frequency')
874
            )
875
        )
876

877
    def layout(self):
1✔
878
        if settings.TARGET_PERMISSIONS_ONLY:
1✔
879
            groups = Div()
1✔
880
        else:
881
            groups = Row('groups')
×
882

883
        # Add filters to layout
884
        filter_layout = Layout(
1✔
885
            Row(
886
                Column(HTML('Exposure Time')),
887
                Column(HTML('No. of Exposures')),
888
                Column(HTML('Block No.')),
889
            )
890
        )
891
        for filter_name in self.valid_filters:
1✔
892
            filter_layout.append(Row(MultiWidgetField(filter_name, attrs={'min': 0})))
1✔
893

894
        return Row(
1✔
895
            Column(
896
                filter_layout,
897
                css_class='col-md-6'
898
            ),
899
            Column(
900
                Row('max_airmass'),
901
                Row(
902
                    PrependedText('min_lunar_distance', '>')
903
                ),
904
                Row('instrument_type'),
905
                Row('proposal'),
906
                Row('observation_mode'),
907
                Row('ipp_value'),
908
                groups,
909
                css_class='col-md-6'
910
            ),
911
        )
912

913

914
class LCOSpectroscopicSequenceForm(LCOOldStyleObservationForm):
1✔
915
    site = forms.ChoiceField(choices=(('any', 'Any'), ('ogg', 'Hawaii'), ('coj', 'Australia')))
1✔
916
    acquisition_radius = forms.FloatField(min_value=0, required=False)
1✔
917
    guider_mode = forms.ChoiceField(choices=[('on', 'On'), ('off', 'Off'), ('optional', 'Optional')], required=True)
1✔
918
    guider_exposure_time = forms.IntegerField(min_value=0)
1✔
919
    cadence_frequency = forms.IntegerField(required=True,
1✔
920
                                           widget=forms.NumberInput(attrs={'placeholder': 'Hours'}))
921

922
    def __init__(self, *args, **kwargs):
1✔
923
        if 'facility_settings' not in kwargs:
1✔
924
            kwargs['facility_settings'] = LCOSettings("LCO")
1✔
925
        super().__init__(*args, **kwargs)
1✔
926

927
        # Massage cadence form to be SNEx-styled
928
        self.fields['name'].label = ''
1✔
929
        self.fields['name'].widget.attrs['placeholder'] = 'Name'
1✔
930
        self.fields['min_lunar_distance'].widget.attrs['placeholder'] = 'Degrees'
1✔
931
        self.fields['cadence_strategy'] = forms.ChoiceField(
1✔
932
            choices=[('', 'Once in the next'), ('ResumeCadenceAfterFailureStrategy', 'Repeating every')],
933
            required=False,
934
            label=''
935
        )
936
        self.fields['cadence_frequency'].label = ''
1✔
937

938
        # Remove start and end because those are determined by the cadence
939
        for field_name in ['instrument_type']:
1✔
940
            self.fields.pop(field_name)
1✔
941
        if self.fields.get('groups'):
1✔
942
            self.fields['groups'].label = 'Data granted to'
×
943
        for field_name in ['start', 'end']:
1✔
944
            self.fields[field_name].widget = forms.HiddenInput()
1✔
945
            self.fields[field_name].required = False
1✔
946

947
        self.helper.layout = Layout(
1✔
948
            Div(
949
                Column('name'),
950
                Column('cadence_strategy'),
951
                Column(AppendedText('cadence_frequency', 'Hours')),
952
                css_class='form-row'
953
            ),
954
            Layout('facility', 'target_id', 'observation_type'),
955
            self.layout(),
956
            self.button_layout()
957
        )
958

959
    def _build_configuration(self):
1✔
960
        configuration = super()._build_configuration()
×
961
        configuration['type'] = 'SPECTRUM'
×
962
        return configuration
×
963

964
    def _build_instrument_configs(self):
1✔
965
        instrument_configs = super()._build_instrument_configs()
1✔
966
        instrument_configs[0]['optical_elements'].pop('filter')
1✔
967
        instrument_configs[0]['optical_elements']['slit'] = self.cleaned_data['filter']
1✔
968

969
        return instrument_configs
1✔
970

971
    def _build_acquisition_config(self):
1✔
972
        acquisition_config = super()._build_acquisition_config()
1✔
973
        # SNEx uses WCS mode if no acquisition radius is specified, and BRIGHTEST otherwise
974
        if not self.cleaned_data['acquisition_radius']:
1✔
975
            acquisition_config['mode'] = 'WCS'
1✔
976
        else:
977
            acquisition_config['mode'] = 'BRIGHTEST'
1✔
978
            acquisition_config['extra_params'] = {
1✔
979
                'acquire_radius': self.cleaned_data['acquisition_radius']
980
            }
981

982
        return acquisition_config
1✔
983

984
    def _build_guiding_config(self):
1✔
985
        guiding_config = super()._build_guiding_config()
1✔
986
        guiding_config['mode'] = 'ON' if self.cleaned_data['guider_mode'] in ['on', 'optional'] else 'OFF'
1✔
987
        guiding_config['optional'] = 'true' if self.cleaned_data['guider_mode'] == 'optional' else 'false'
1✔
988
        return guiding_config
1✔
989

990
    def _build_location(self):
1✔
991
        location = super()._build_location()
1✔
992
        site = self.cleaned_data['site']
1✔
993
        if site != 'any':
1✔
994
            location['site'] = site
1✔
995
        return location
1✔
996

997
    def clean_start(self):
1✔
998
        """
999
        Unless included in the submission, set the start time to now.
1000
        """
1001
        start = self.cleaned_data.get('start')
1✔
1002
        if not start:  # Start is in cleaned_data as an empty string if it was not submitted, so check falsiness
1✔
1003
            start = datetime.strftime(datetime.now(), '%Y-%m-%dT%H:%M:%S')
1✔
1004
        return start
1✔
1005

1006
    def clean_end(self):
1✔
1007
        """
1008
        Override clean_end in order to avoid the superclass attempting to date-parse an empty string.
1009
        """
1010
        return self.cleaned_data.get('end')
1✔
1011

1012
    def clean(self):
1✔
1013
        """
1014
        This clean method does the following:
1015
            - Hardcodes instrument type as "2M0-FLOYDS-SCICAM" because it's the only instrument this form uses
1016
            - Adds a start time of "right now", as the spectroscopic sequence form does not allow for specification
1017
              of a start time.
1018
            - Adds an end time that corresponds with the cadence frequency
1019
            - Adds the cadence strategy to the form if "repeat" was the selected "cadence_type". If "once" was
1020
              selected, the observation is submitted as a single observation.
1021
        """
1022
        cleaned_data = super().clean()
1✔
1023
        cleaned_data['instrument_type'] = '2M0-FLOYDS-SCICAM'  # SNEx only submits spectra to FLOYDS
1✔
1024
        start = cleaned_data.get('start')
1✔
1025
        cleaned_data['end'] = datetime.strftime(parse(start) + timedelta(hours=cleaned_data['cadence_frequency']),
1✔
1026
                                                '%Y-%m-%dT%H:%M:%S')
1027

1028
        return cleaned_data
1✔
1029

1030
    def instrument_choices(self):
1✔
1031
        # SNEx only uses the Spectroscopic Sequence Form with FLOYDS
1032
        # This doesn't need to be sorted because it will only return one instrument
1033
        return [(k, v['name'])
1✔
1034
                for k, v in self._get_instruments().items()
1035
                if k == '2M0-FLOYDS-SCICAM']
1036

1037
    def all_optical_element_choices(self, use_code_only=False):
1✔
1038
        return sorted(set([
1✔
1039
            (f['code'], f['name']) for name, ins in self._get_instruments().items() for f in
1040
            ins['optical_elements'].get('slits', []) if name == '2M0-FLOYDS-SCICAM'
1041
        ]), key=lambda filter_tuple: filter_tuple[1])
1042

1043
    def layout(self):
1✔
1044
        if settings.TARGET_PERMISSIONS_ONLY:
1✔
1045
            groups = Div()
1✔
1046
        else:
1047
            groups = Row('groups')
×
1048
        return Div(
1✔
1049
            Row('exposure_count'),
1050
            Row('exposure_time'),
1051
            Row('max_airmass'),
1052
            Row(PrependedText('min_lunar_distance', '>')),
1053
            Row('site'),
1054
            Row('filter'),
1055
            Row('acquisition_radius'),
1056
            Row('guider_mode'),
1057
            Row('guider_exposure_time'),
1058
            Row('proposal'),
1059
            Row('observation_mode'),
1060
            Row('ipp_value'),
1061
            groups,
1062
        )
1063

1064

1065
class LCOFacility(OCSFacility):
1✔
1066
    """
1067
    The ``LCOFacility`` is the interface to the Las Cumbres Observatory Observation Portal. For information regarding
1068
    LCO observing and the available parameters, please see https://observe.lco.global/help/.
1069
    """
1070
    name = 'LCO'
1✔
1071
    observation_forms = {
1✔
1072
        'IMAGING': LCOImagingObservationForm,
1073
        'MUSCAT_IMAGING': LCOMuscatImagingObservationForm,
1074
        'SPECTRA': LCOSpectroscopyObservationForm,
1075
        'PHOTOMETRIC_SEQUENCE': LCOPhotometricSequenceForm,
1076
        'SPECTROSCOPIC_SEQUENCE': LCOSpectroscopicSequenceForm
1077
    }
1078

1079
    def __init__(self, facility_settings=LCOSettings('LCO')):
1✔
1080
        super().__init__(facility_settings=facility_settings)
1✔
1081

1082
    # TODO: this should be called get_form_class
1083
    def get_form(self, observation_type):
1✔
1084
        return self.observation_forms.get(observation_type, LCOOldStyleObservationForm)
1✔
1085

1086
    # TODO: this should be called get_template_form_class
1087
    def get_template_form(self, observation_type):
1✔
1088
        return LCOTemplateBaseForm
×
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc