• 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

72.92
/tom_observations/facilities/ocs.py
1
from datetime import datetime
1✔
2
import logging
1✔
3
import requests
1✔
4
from urllib.parse import urlencode, urljoin
1✔
5

6
from astropy import units as u
1✔
7
from crispy_forms.bootstrap import Accordion, AccordionGroup, TabHolder, Tab, Alert
1✔
8
from crispy_forms.layout import Div, HTML, Layout
1✔
9
from dateutil.parser import parse
1✔
10
from django import forms
1✔
11
from django.conf import settings
1✔
12
from django.core.cache import cache
1✔
13

14
from tom_common.exceptions import ImproperCredentialsException
1✔
15
from tom_observations.cadence import CadenceForm
1✔
16
from tom_observations.facility import BaseRoboticObservationFacility, BaseRoboticObservationForm, get_service_class
1✔
17
from tom_observations.observation_template import GenericTemplateForm
1✔
18
from tom_targets.models import Target, REQUIRED_NON_SIDEREAL_FIELDS, REQUIRED_NON_SIDEREAL_FIELDS_PER_SCHEME
1✔
19

20
logger = logging.getLogger(__name__)
1✔
21

22

23
class OCSSettings():
1✔
24
    """ Class encapsulates the settings from django for this Facility, and some of the options for
25
        an OCS form implementation. The facility_name is used for retrieving the settings from the
26
        FACILITIES dictionary in settings.py.
27
    """
28
    default_settings = {
1✔
29
        'portal_url': '',
30
        'archive_url': '',
31
        'api_key': '',
32
        'max_instrument_configs': 5,
33
        'max_configurations': 5
34
    }
35
    # These class variables describe default help text for a variety of OCS fields.
36
    # Override them as desired for a specific OCS implementation.
37
    ipp_value_help = """
1✔
38
            Value between 0.5 to 2.0.
39
            <a href="https://lco.global/documents/20/the_new_priority_factor.pdf" target="_blank">
40
                More information about Intra Proprosal Priority (IPP).
41
            </a>
42
    """
43

44
    observation_mode_help = """
1✔
45
        <a href="https://lco.global/documentation/special-scheduling-modes/" target="_blank">
46
            More information about Rapid Response mode.
47
        </a>
48
    """
49

50
    optimization_type_help = """
1✔
51
        Scheduling optimization emphasis: Time for ASAP, or Airmass for minimum airmass.
52
    """
53

54
    end_help = ""
1✔
55

56
    instrument_type_help = ""
1✔
57

58
    exposure_time_help = ""
1✔
59

60
    max_lunar_phase_help = """
1✔
61
        Value between 0 (new moon) and 1 (full moon).
62
    """
63

64
    static_cadencing_help = """
1✔
65
        <em>Static cadence parameters.</em> Leave blank if no cadencing is desired.
66
    """
67

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

73
    max_airmass_help = """
1✔
74
        Maximum acceptable airmass at which the observation can be scheduled.
75
        A plane-parallel atmosphere is assumed.
76
    """
77

78
    def __init__(self, facility_name):
1✔
79
        self.facility_name = facility_name
1✔
80

81
    def get_setting(self, key):
1✔
82
        return settings.FACILITIES.get(self.facility_name, self.default_settings).get(key, self.default_settings[key])
1✔
83

84
    def get_observing_states(self):
1✔
85
        return [
×
86
            'PENDING', 'COMPLETED', 'WINDOW_EXPIRED', 'CANCELED', 'FAILURE_LIMIT_REACHED', 'NOT_ATTEMPTED'
87
        ]
88

89
    def get_pending_observing_states(self):
1✔
90
        return ['PENDING']
×
91

92
    def get_successful_observing_states(self):
1✔
93
        return ['COMPLETED']
1✔
94

95
    def get_failed_observing_states(self):
1✔
96
        return ['WINDOW_EXPIRED', 'CANCELED', 'FAILURE_LIMIT_REACHED', 'NOT_ATTEMPTED']
1✔
97

98
    def get_terminal_observing_states(self):
1✔
99
        return self.get_successful_observing_states() + self.get_failed_observing_states()
1✔
100

101
    def get_fits_facility_header_keyword(self):
1✔
102
        """ Should return the fits header keyword that stores what facility the data was taken at
103
        """
104
        return 'ORIGIN'
×
105

106
    def get_fits_facility_header_value(self):
1✔
107
        """ Should return the expected value in the fits facility header for data from this facility
108
        """
109
        return 'OCS'
×
110

111
    def get_fits_header_dateobs_keyword(self):
1✔
112
        """ Should return the fits header keyword that stores the date the data was taken at
113
        """
114
        return 'DATE-OBS'
×
115

116
    def get_data_flux_constant(self):
1✔
117
        return (1e-15 * u.erg) / (u.cm ** 2 * u.second * u.angstrom)
×
118

119
    def get_data_wavelength_units(self):
1✔
120
        return u.angstrom
×
121

122
    def get_sites(self):
1✔
123
        """
124
        Return an iterable of dictionaries that contain the information
125
        necessary to be used in the planning (visibility) tool.
126
        Format:
127
        {
128
            'Site Name': {
129
                'sitecode': 'tst',
130
                'latitude': -31.272,
131
                'longitude': 149.07,
132
                'elevation': 1116
133
            },
134
        }
135
        """
136
        return {}
1✔
137

138
    def get_weather_urls(self):
1✔
139
        """ Return a dictionary containing urls to check the weather for each site in your sites dictionary
140
        Format:
141
        {
142
            'code': 'OCS',
143
            'sites': [
144
                {
145
                    'code': sitecode,
146
                    'weather_url': weather_url for site
147
                }
148
            ]
149
        }
150
        """
151
        return {
×
152
            'code': self.facility_name,
153
            'sites': []
154
        }
155

156

157
def make_request(*args, **kwargs):
1✔
158
    response = requests.request(*args, **kwargs)
1✔
159
    if 401 <= response.status_code <= 403:
1✔
160
        raise ImproperCredentialsException('OCS: ' + str(response.content))
1✔
161
    elif 400 == response.status_code:
1✔
162
        raise forms.ValidationError(f'OCS: {str(response.content)}')
×
163
    response.raise_for_status()
1✔
164
    return response
1✔
165

166

167
class OCSBaseForm(forms.Form):
1✔
168
    """ The OCSBaseForm assumes nothing of fields, and just adds helper methods for getting
169
        data from an OCS portal to other form subclasses.
170
    """
171
    def __init__(self, *args, **kwargs):
1✔
172
        if 'facility_settings' not in kwargs:
1✔
173
            kwargs['facility_settings'] = OCSSettings("OCS")
1✔
174
        self.facility_settings = kwargs.pop('facility_settings')
1✔
175
        super().__init__(*args, **kwargs)
1✔
176

177
    def target_group_choices(self, include_self=True):
1✔
178
        target_id = self.data.get('target_id')
1✔
179
        if not target_id:
1✔
180
            target_id = self.initial.get('target_id')
×
181
        try:
1✔
182
            target_name = Target.objects.get(pk=target_id).name
1✔
183
            group_targets = Target.objects.filter(targetlist__targets__pk=target_id).exclude(
1✔
184
                pk=target_id).order_by('name').distinct().values_list('pk', 'name')
185
            if include_self:
1✔
186
                return [(target_id, target_name)] + list(group_targets)
1✔
187
            else:
188
                return list(group_targets)
×
189
        except Target.DoesNotExist:
×
190
            return []
×
191

192
    def _get_instruments(self):
1✔
193
        cached_instruments = cache.get(f'{self.facility_settings.facility_name}_instruments')
1✔
194

195
        if not cached_instruments:
1✔
196
            logger.warning("Instruments not cached, getting them again!!!")
1✔
197
            response = make_request(
1✔
198
                'GET',
199
                urljoin(self.facility_settings.get_setting('portal_url'), '/api/instruments/'),
200
                headers={'Authorization': 'Token {0}'.format(self.facility_settings.get_setting('api_key'))}
201
            )
202
            cached_instruments = {k: v for k, v in response.json().items()}
1✔
203
            cache.set(f'{self.facility_settings.facility_name}_instruments', cached_instruments, 3600)
1✔
204
        return cached_instruments
1✔
205

206
    def get_instruments(self):
1✔
207
        return self._get_instruments()
1✔
208

209
    def instrument_choices(self):
1✔
210
        return sorted([(k, v.get('name')) for k, v in self.get_instruments().items()], key=lambda inst: inst[1])
1✔
211

212
    def mode_choices(self, mode_type, use_code_only=False):
1✔
213
        return sorted(set([
1✔
214
            (f['code'], f['code'] if use_code_only else f['name']) for ins in self.get_instruments().values() for f in
215
            ins.get('modes', {}).get(mode_type, {}).get('modes', [])
216
        ]), key=lambda filter_tuple: filter_tuple[1])
217

218
    def filter_choices_for_group(self, oe_group, use_code_only=False):
1✔
219
        return sorted(set([
1✔
220
            (f['code'], f['code'] if use_code_only else f['name']) for ins in self.get_instruments().values() for f in
221
            ins['optical_elements'].get(oe_group, []) if f.get('schedulable')
222
        ]), key=lambda filter_tuple: filter_tuple[1])
223

224
    def instrument_to_default_configuration_type(self, instrument_type):
1✔
225
        return self.get_instruments().get(instrument_type, {}).get('default_configuration_type', '')
1✔
226

227
    def all_optical_element_choices(self, use_code_only=False):
1✔
228
        optical_elements = set()
1✔
229
        for ins in self.get_instruments().values():
1✔
230
            for oe_group in ins.get('optical_elements', {}).values():
1✔
231
                for element in oe_group:
1✔
232
                    if element.get('schedulable'):
1✔
233
                        optical_elements.add((element['code'], element['code'] if use_code_only else element['name']))
1✔
234
        return sorted(optical_elements, key=lambda x: x[1])
1✔
235

236
    def get_optical_element_groups(self):
1✔
237
        oe_groups = set()
1✔
238
        for instrument in self.get_instruments().values():
1✔
239
            for oe_group in instrument['optical_elements'].keys():
1✔
240
                oe_groups.add(oe_group.rstrip('s'))
1✔
241
        return sorted(oe_groups)
1✔
242

243
    def configuration_type_choices(self):
1✔
244
        all_config_types = set()
×
245
        for instrument in self.get_instruments().values():
×
246
            config_types = instrument.get('configuration_types', {}).values()
×
247
            all_config_types.update(
×
248
                {(config_type.get('code'), config_type.get('name'))
249
                 for config_type in config_types if config_type.get('schedulable')}
250
            )
251
        return sorted(all_config_types, key=lambda config_type: config_type[1])
×
252

253
    def proposal_choices(self):
1✔
254
        cached_proposals = cache.get(f'{self.facility_settings.facility_name}_proposals')
1✔
255
        if not cached_proposals:
1✔
256
            response = make_request(
1✔
257
                'GET',
258
                urljoin(self.facility_settings.get_setting('portal_url'), '/api/profile/'),
259
                headers={'Authorization': 'Token {0}'.format(self.facility_settings.get_setting('api_key'))}
260
            )
261
            cached_proposals = []
1✔
262
            for p in response.json()['proposals']:
1✔
263
                if p['current']:
1✔
264
                    cached_proposals.append((p['id'], '{} ({})'.format(p['title'], p['id'])))
1✔
265
            cache.set(f'{self.facility_settings.facility_name}_proposals', cached_proposals, 3600)
1✔
266
        return cached_proposals
1✔
267

268

269
class OCSTemplateBaseForm(GenericTemplateForm, OCSBaseForm):
1✔
270
    ipp_value = forms.FloatField()
1✔
271
    exposure_count = forms.IntegerField(min_value=1)
1✔
272
    exposure_time = forms.FloatField(min_value=0.1)
1✔
273
    max_airmass = forms.FloatField()
1✔
274

275
    def __init__(self, *args, **kwargs):
1✔
276
        super().__init__(*args, **kwargs)
1✔
277
        self.fields['proposal'] = forms.ChoiceField(choices=self.proposal_choices())
1✔
278
        self.fields['filter'] = forms.ChoiceField(choices=self.all_optical_element_choices())
1✔
279
        self.fields['instrument_type'] = forms.ChoiceField(choices=self.instrument_choices())
1✔
280
        for field_name in ['groups', 'target_id']:
1✔
281
            self.fields.pop(field_name, None)
1✔
282
        for field in self.fields:
1✔
283
            if field != 'template_name':
1✔
284
                self.fields[field].required = False
1✔
285
        self.helper.layout = Layout(
1✔
286
            self.common_layout,
287
            Div(
288
                Div(
289
                    'proposal', 'ipp_value', 'filter', 'instrument_type',
290
                    css_class='col'
291
                ),
292
                Div(
293
                    'exposure_count', 'exposure_time', 'max_airmass',
294
                    css_class='col'
295
                ),
296
                css_class='form-row',
297
            )
298
        )
299

300

301
class OCSAdvancedExpansionsLayout(Layout):
1✔
302
    def __init__(self, form_name, facility_settings, *args, **kwargs):
1✔
303
        self.facility_settings = facility_settings
1✔
304
        super().__init__(
1✔
305
            Accordion(
306
                *self._get_accordion_group(form_name)
307
            )
308
        )
309

310
    def _get_accordion_group(self, form_name):
1✔
311
        return (
1✔
312
            AccordionGroup(
313
                'Cadence / Dither / Mosaic',
314
                Alert(
315
                    content="""Using the following sections each result in expanding portions of the Request
316
                                on submission. You should only combine these if you know what you are doing.
317
                            """,
318
                    css_class='alert-warning'
319
                ),
320
                TabHolder(
321
                    Tab('Cadence',
322
                        Div(
323
                            HTML(f'''<br/><p>{self.facility_settings.static_cadencing_help}</p>'''),
324
                        ),
325
                        Div(
326
                            Div(
327
                                'period',
328
                                css_class='col'
329
                            ),
330
                            Div(
331
                                'jitter',
332
                                css_class='col'
333
                            ),
334
                            css_class='form-row'
335
                        ),
336
                        css_id=f'{form_name}_cadence'
337
                        ),
338
                    Tab('Dithering',
339
                        Alert(
340
                            content="Dithering will only be applied if you have a single Configuration specified.",
341
                            css_class='alert-warning'
342
                        ),
343
                        Div(
344
                            Div(
345
                                'dither_pattern',
346
                                css_class='col'
347
                            ),
348
                            Div(
349
                                'dither_num_points',
350
                                css_class='col'
351
                            ),
352
                            css_class='form-row'
353
                        ),
354
                        Div(
355
                            Div(
356
                                'dither_point_spacing',
357
                                css_class='col'
358
                            ),
359
                            Div(
360
                                'dither_line_spacing',
361
                                css_class='col'
362
                            ),
363
                            css_class='form-row'
364
                        ),
365
                        Div(
366
                            Div(
367
                                'dither_num_rows',
368
                                css_class='col'
369
                            ),
370
                            Div(
371
                                'dither_num_columns',
372
                                css_class='col'
373
                            ),
374
                            css_class='form-row'
375
                        ),
376
                        Div(
377
                            Div(
378
                                'dither_orientation',
379
                                css_class='col'
380
                            ),
381
                            Div(
382
                                'dither_center',
383
                                css_class='col'
384
                            ),
385
                            css_class='form-row'
386
                        ),
387
                        css_id=f'{form_name}_dithering'
388
                        ),
389
                    Tab('Mosaicing',
390
                        Alert(
391
                            content="Mosaicing will only be applied if you have a single Configuration specified.",
392
                            css_class='alert-warning'
393
                        ),
394
                        Div(
395
                            Div(
396
                                'mosaic_pattern',
397
                                css_class='col'
398
                            ),
399
                            Div(
400
                                'mosaic_num_points',
401
                                css_class='col'
402
                            ),
403
                            css_class='form-row'
404
                        ),
405
                        Div(
406
                            Div(
407
                                'mosaic_point_overlap',
408
                                css_class='col'
409
                            ),
410
                            Div(
411
                                'mosaic_line_overlap',
412
                                css_class='col'
413
                            ),
414
                            css_class='form-row'
415
                        ),
416
                        Div(
417
                            Div(
418
                                'mosaic_num_rows',
419
                                css_class='col'
420
                            ),
421
                            Div(
422
                                'mosaic_num_columns',
423
                                css_class='col'
424
                            ),
425
                            css_class='form-row'
426
                        ),
427
                        Div(
428
                            Div(
429
                                'mosaic_orientation',
430
                                css_class='col'
431
                            ),
432
                            Div(
433
                                'mosaic_center',
434
                                css_class='col'
435
                            ),
436
                            css_class='form-row'
437
                        ),
438
                        css_id=f'{form_name}_mosaicing'
439
                        )
440
                ),
441
                active=False,
442
                css_id=f'{form_name}-expansions-group'
443
            )
444
        )
445

446

447
class OCSConfigurationLayout(Layout):
1✔
448
    def __init__(self, form_name, facility_settings, instrument_config_layout_class, oe_groups, *args, **kwargs):
1✔
449
        self.form_name = form_name
1✔
450
        self.facility_settings = facility_settings
1✔
451
        self.instrument_config_layout_class = instrument_config_layout_class
1✔
452
        super().__init__(
1✔
453
            Div(
454
                HTML('''<br/><h2>Configurations:</h2>''')
455
            ),
456
            TabHolder(
457
                *self._get_config_tabs(oe_groups, facility_settings.get_setting('max_configurations'))
458
            )
459
        )
460

461
    def _get_config_tabs(self, oe_groups, num_tabs):
1✔
462
        tabs = []
1✔
463
        for i in range(num_tabs):
1✔
464
            tabs.append(
1✔
465
                Tab(f'{i+1}',
466
                    *self._get_config_layout(i + 1, oe_groups),
467
                    css_id=f'{self.form_name}_config_{i+1}'
468
                    ),
469
            )
470
        return tuple(tabs)
1✔
471

472
    def _get_config_layout(self, instance, oe_groups):
1✔
473
        return (
1✔
474
            Alert(
475
                content="""When using multiple configurations, ensure the instrument types are all
476
                            available on the same telescope class.
477
                        """,
478
                css_class='alert-warning'
479
            ),
480
            Div(
481
                Div(
482
                    f'c_{instance}_instrument_type',
483
                    css_class='col'
484
                ),
485
                Div(
486
                    f'c_{instance}_configuration_type',
487
                    css_class='col'
488
                ),
489
                css_class='form-row'
490
            ),
491
            Div(
492
                Div(
493
                    f'c_{instance}_repeat_duration',
494
                    css_class='col'
495
                ),
496
                css_class='form-row'
497
            ),
498
            *self._get_target_override(instance),
499
            Accordion(
500
                *self.get_initial_accordion_items(instance),
501
                AccordionGroup('Instrument Configurations',
502
                               self.instrument_config_layout_class(self.form_name, self.facility_settings,
503
                                                                   instance, oe_groups),
504
                               css_id=f'{self.form_name}-c-{instance}-instrument-configs'
505
                               ),
506
                AccordionGroup('Constraints',
507
                               Div(
508
                                   Div(
509
                                       f'c_{instance}_max_airmass',
510
                                       css_class='col'
511
                                   ),
512
                                   css_class='form-row'
513
                               ),
514
                               Div(
515
                                   Div(
516
                                       f'c_{instance}_min_lunar_distance',
517
                                       css_class='col'
518
                                   ),
519
                                   Div(
520
                                       f'c_{instance}_max_lunar_phase',
521
                                       css_class='col'
522
                                   ),
523
                                   css_class='form-row'
524
                               ),
525
                               ),
526
                *self.get_final_accordion_items(instance)
527
            )
528
        )
529

530
    def get_initial_accordion_items(self, instance):
1✔
531
        """ Override in subclasses to add items to the begining of the accordion group
532
        """
533
        return ()
1✔
534

535
    def get_final_accordion_items(self, instance):
1✔
536
        """ Override in the subclasses to add items at the end of the accordion group
537
        """
538
        return ()
×
539

540
    def _get_target_override(self, instance):
1✔
541
        if instance == 1:
1✔
542
            return ()
1✔
543
        else:
544
            return (
1✔
545
                Div(
546
                    f'c_{instance}_target_override',
547
                    css_class='form-row'
548
                )
549
            )
550

551

552
class OCSInstrumentConfigLayout(Layout):
1✔
553
    def __init__(self, form_name, facility_settings, config_instance, oe_groups, *args, **kwargs):
1✔
554
        self.form_name = form_name
1✔
555
        self.facility_settings = facility_settings
1✔
556
        super().__init__(
1✔
557
            TabHolder(
558
                *self._get_ic_tabs(config_instance, oe_groups, facility_settings.get_setting('max_instrument_configs'))
559
            )
560
        )
561

562
    def _get_ic_tabs(self, config_instance, oe_groups, num_tabs):
1✔
563
        tabs = []
1✔
564
        for i in range(num_tabs):
1✔
565
            tabs.append(
1✔
566
                Tab(f'{i+1}',
567
                    *self._get_ic_layout(config_instance, i + 1, oe_groups),
568
                    css_id=f'{self.form_name}_c_{config_instance}_ic_{i+1}'
569
                    ),
570
            )
571
        return tuple(tabs)
1✔
572

573
    def _get_oe_groups_layout(self, config_instance, instance, oe_groups):
1✔
574
        oe_groups_layout = []
1✔
575
        for oe_group1, oe_group2 in zip(*[iter(oe_groups)] * 2):
1✔
576
            oe_groups_layout.append(
1✔
577
                Div(
578
                    Div(
579
                        f'c_{config_instance}_ic_{instance}_{oe_group1}',
580
                        css_class='col'
581
                    ),
582
                    Div(
583
                        f'c_{config_instance}_ic_{instance}_{oe_group2}',
584
                        css_class='col'
585
                    ),
586
                    css_class='form-row'
587
                )
588
            )
589
        if len(oe_groups) % 2 == 1:
1✔
590
            # We have one excess oe_group, so add it here
591
            oe_groups_layout.append(
1✔
592
                Div(
593
                    Div(
594
                        f'c_{config_instance}_ic_{instance}_{oe_groups[-1]}',
595
                        css_class='col'
596
                    ),
597
                    css_class='form-row'
598
                )
599
            )
600
        return oe_groups_layout
1✔
601

602
    def get_initial_ic_items(self, config_instance, instance):
1✔
603
        """ Override in subclasses to add items to the begining of the inst config
604
        """
605
        return ()
1✔
606

607
    def get_final_ic_items(self, config_instance, instance):
1✔
608
        """ Override in the subclasses to add items at the end of the inst config
609
        """
610
        return ()
1✔
611

612
    def _get_ic_layout(self, config_instance, instance, oe_groups):
1✔
613
        return (
1✔
614
            *self.get_initial_ic_items(config_instance, instance),
615
            Div(
616
                Div(
617
                    f'c_{config_instance}_ic_{instance}_exposure_time',
618
                    css_class='col'
619
                ),
620
                Div(
621
                    f'c_{config_instance}_ic_{instance}_exposure_count',
622
                    css_class='col'
623
                ),
624
                css_class='form-row'
625
            ),
626
            *self._get_oe_groups_layout(config_instance, instance, oe_groups),
627
            *self.get_final_ic_items(config_instance, instance)
628
        )
629

630

631
class OCSBaseObservationForm(BaseRoboticObservationForm, OCSBaseForm):
1✔
632
    """
633
    The OCSBaseObservationForm provides the base set of utilities to construct an observation at an OCS facility.
634
    It must be subclassed to be used, as some methods are not implemented in this class.
635
    """
636
    name = forms.CharField()
1✔
637
    start = forms.CharField(widget=forms.TextInput(attrs={'type': 'date'}))
1✔
638
    configuration_repeats = forms.IntegerField(
1✔
639
        min_value=1,
640
        initial=1,
641
        required=False,
642
        label='Configuration Repeats',
643
        help_text='Number of times to repeat the set of configurations, usually used for nodding between 2+ targets'
644
    )
645
    period = forms.FloatField(help_text='Decimal Hours', required=False, min_value=0.0)
1✔
646
    jitter = forms.FloatField(help_text='Decimal Hours', required=False, min_value=0.0)
1✔
647

648
    def __init__(self, *args, **kwargs):
1✔
649
        super().__init__(*args, **kwargs)
1✔
650
        self.fields['proposal'] = forms.ChoiceField(choices=self.proposal_choices())
1✔
651
        self.fields['ipp_value'] = forms.FloatField(
1✔
652
            label='Intra Proposal Priority (IPP factor)',
653
            min_value=0.5,
654
            max_value=2,
655
            initial=1.05,
656
            help_text=self.facility_settings.ipp_value_help
657
        )
658
        self.fields['end'] = forms.CharField(widget=forms.TextInput(attrs={'type': 'date'}),
1✔
659
                                             help_text=self.facility_settings.end_help)
660
        self.fields['observation_mode'] = forms.ChoiceField(
1✔
661
            choices=(('NORMAL', 'Normal'), ('RAPID_RESPONSE', 'Rapid-Response'), ('TIME_CRITICAL', 'Time-Critical')),
662
            help_text=self.facility_settings.observation_mode_help
663
        )
664
        self.fields['optimization_type'] = forms.ChoiceField(
1✔
665
            choices=(('TIME', 'Time'), ('AIRMASS', 'Airmass')),
666
            required=False,
667
            help_text=self.facility_settings.optimization_type_help
668
        )
669
        # self.helper.layout = Layout(
670
        #     self.common_layout,
671
        #     self.layout(),
672
        #     self.button_layout()
673
        # )
674

675
    def clean_start(self):
1✔
676
        start = self.cleaned_data['start']
1✔
677
        return parse(start).isoformat()
1✔
678

679
    def clean_end(self):
1✔
680
        end = self.cleaned_data['end']
1✔
681
        return parse(end).isoformat()
1✔
682

683
    def validate_at_facility(self):
1✔
684
        obs_module = get_service_class(self.cleaned_data['facility'])
1✔
685
        response = obs_module().validate_observation(self.observation_payload())
1✔
686
        if response.get('request_durations', {}).get('duration'):
1✔
687
            duration = response['request_durations']['duration']
1✔
688
            self.validation_message = f"This observation is valid with a duration of {duration} seconds."
1✔
689
        if response.get('errors'):
1✔
690
            self.add_error(None, self._flatten_error_dict(response['errors']))
1✔
691

692
    def is_valid(self):
1✔
693
        super().is_valid()
1✔
694
        self.validate_at_facility()
1✔
695
        if self._errors:
1✔
696
            logger.warn(f'Facility submission has errors {self._errors}')
1✔
697
        return not self._errors
1✔
698

699
    def _flatten_error_dict(self, error_dict):
1✔
700
        non_field_errors = []
1✔
701
        for k, v in error_dict.items():
1✔
702
            if isinstance(v, list):
1✔
703
                for i in v:
1✔
704
                    if isinstance(i, str):
1✔
705
                        if k in self.fields:
1✔
706
                            self.add_error(k, i)
1✔
707
                        else:
708
                            non_field_errors.append('{}: {}'.format(k, i))
1✔
709
                    if isinstance(i, dict):
1✔
710
                        non_field_errors.append(self._flatten_error_dict(i))
1✔
711
            elif isinstance(v, str):
1✔
712
                if k in self.fields:
1✔
713
                    self.add_error(k, v)
1✔
714
                else:
715
                    non_field_errors.append('{}: {}'.format(k, v))
1✔
716
            elif isinstance(v, dict):
1✔
717
                non_field_errors.append(self._flatten_error_dict(v))
1✔
718

719
        return non_field_errors
1✔
720

721
    def _build_target_extra_params(self, configuration_id=1):
1✔
722
        return {}
×
723

724
    def _build_target_fields(self, target_id, configuration_id=1):
1✔
725
        target = Target.objects.get(pk=target_id)
1✔
726
        target_fields = {
1✔
727
            'name': target.name,
728
        }
729
        if target.type == Target.SIDEREAL:
1✔
730
            target_fields['type'] = 'ICRS'
1✔
731
            target_fields['ra'] = target.ra
1✔
732
            target_fields['dec'] = target.dec
1✔
733
            target_fields['proper_motion_ra'] = target.pm_ra
1✔
734
            target_fields['proper_motion_dec'] = target.pm_dec
1✔
735
            target_fields['epoch'] = target.epoch
1✔
736
        elif target.type == Target.NON_SIDEREAL:
1✔
737
            target_fields['type'] = 'ORBITAL_ELEMENTS'
1✔
738
            # Mapping from TOM field names to OCS API field names, for fields
739
            # where there are differences
740
            field_mapping = {
1✔
741
                'inclination': 'orbinc',
742
                'lng_asc_node': 'longascnode',
743
                'arg_of_perihelion': 'argofperih',
744
                'semimajor_axis': 'meandist',
745
                'mean_anomaly': 'meananom',
746
                'mean_daily_motion': 'dailymot',
747
                'epoch_of_elements': 'epochofel',
748
                'epoch_of_perihelion': 'epochofperih',
749
            }
750
            # The fields to include in the payload depend on the scheme. Add
751
            # only those that are required
752
            fields = (REQUIRED_NON_SIDEREAL_FIELDS
1✔
753
                      + REQUIRED_NON_SIDEREAL_FIELDS_PER_SCHEME[target.scheme])
754
            for field in fields:
1✔
755
                ocs_field = field_mapping.get(field, field)
1✔
756
                target_fields[ocs_field] = getattr(target, field)
1✔
757

758
            #
759
            # Handle extra_params
760
            #
761
            if 'extra_params' not in target_fields:
1✔
762
                target_fields['extra_params'] = {}
1✔
763
            target_fields['extra_params'].update(self._build_target_extra_params(configuration_id))
1✔
764

765
        return target_fields
1✔
766

767
    def _build_acquisition_config(self, configuration_id=1):
1✔
768
        acquisition_config = {}
1✔
769

770
        return acquisition_config
1✔
771

772
    def _build_guiding_config(self, configuration_id=1):
1✔
773
        guiding_config = {}
1✔
774

775
        return guiding_config
1✔
776

777
    def _build_instrument_configs(self):
1✔
778
        return []
×
779

780
    def _build_configuration(self):
1✔
781
        configuration = {
1✔
782
            'type': self.instrument_to_default_configuration_type(self.cleaned_data['instrument_type']),
783
            'instrument_type': self.cleaned_data['instrument_type'],
784
            'target': self._build_target_fields(self.cleaned_data['target_id']),
785
            'instrument_configs': self._build_instrument_configs(),
786
            'acquisition_config': self._build_acquisition_config(),
787
            'guiding_config': self._build_guiding_config(),
788
            'constraints': {
789
                'max_airmass': self.cleaned_data['max_airmass'],
790
            }
791
        }
792

793
        if 'min_lunar_distance' in self.cleaned_data and self.cleaned_data.get('min_lunar_distance') is not None:
1✔
794
            configuration['constraints']['min_lunar_distance'] = self.cleaned_data['min_lunar_distance']
1✔
795

796
        return configuration
1✔
797

798
    def _build_location(self):
1✔
799
        return {'telescope_class': self._get_instruments()[self.cleaned_data['instrument_type']]['class']}
1✔
800

801
    def _expand_cadence_request(self, payload):
1✔
802
        payload['requests'][0]['cadence'] = {
1✔
803
            'start': self.cleaned_data['start'],
804
            'end': self.cleaned_data['end'],
805
            'period': self.cleaned_data['period'],
806
            'jitter': self.cleaned_data['jitter']
807
        }
808
        payload['requests'][0]['windows'] = []
1✔
809

810
        # use the OCS Observation Portal candence builder to build the candence
811
        response = make_request(
1✔
812
            'POST',
813
            urljoin(self.facility_settings.get_setting('portal_url'), '/api/requestgroups/cadence/'),
814
            json=payload,
815
            headers={'Authorization': 'Token {0}'.format(self.facility_settings.get_setting('api_key'))}
816
        )
817
        return response.json()
1✔
818

819
    def observation_payload(self):
1✔
820
        payload = {
1✔
821
            'name': self.cleaned_data['name'],
822
            'proposal': self.cleaned_data['proposal'],
823
            'ipp_value': self.cleaned_data['ipp_value'],
824
            'operator': 'SINGLE',
825
            'observation_type': self.cleaned_data['observation_mode'],
826
            'requests': [
827
                {
828
                    'configurations': [self._build_configuration()],
829
                    'windows': [
830
                        {
831
                            'start': self.cleaned_data['start'],
832
                            'end': self.cleaned_data['end']
833
                        }
834
                    ],
835
                    'location': self._build_location()
836
                }
837
            ]
838
        }
839
        if self.cleaned_data.get('period') and self.cleaned_data.get('jitter') is not None:
1✔
840
            payload = self._expand_cadence_request(payload)
1✔
841

842
        return payload
1✔
843

844

845
class OCSFullObservationForm(OCSBaseObservationForm):
1✔
846
    """
847
    The OCSFullObservationForm has all capabilities to construct an observation using the OCS Request language.
848
    While the forms that inherit from it provide a subset of instruments and filters, the
849
    OCSFullObservationForm presents the user with all of the instrument and filter options that the facility has to
850
    offer.
851
    """
852
    dither_pattern = forms.ChoiceField(
1✔
853
        choices=(('', 'None'), ('line', 'Line'), ('grid', 'Grid'), ('spiral', 'Spiral')),
854
        required=False,
855
        help_text='Expand your Instrument Configurations with a set of offsets from the target following a pattern.'
856
    )
857
    dither_num_points = forms.IntegerField(min_value=2, label='Number of Points',
1✔
858
                                           help_text='Number of Points in the pattern (Line and Spiral only).',
859
                                           required=False)
860
    dither_point_spacing = forms.FloatField(
1✔
861
        label='Point Spacing', help_text='Vertical spacing between offsets.', required=False, min_value=0.0)
862
    dither_line_spacing = forms.FloatField(
1✔
863
        label='Line Spacing', help_text='Horizontal spacing between offsets (Grid only).',
864
        required=False, min_value=0.0)
865
    dither_orientation = forms.FloatField(
1✔
866
        label='Orientation',
867
        help_text='Angular rotation of the pattern in degrees, measured clockwise East of North (Line and Grid only).',
868
        required=False, min_value=0.0)
869
    dither_num_rows = forms.IntegerField(
1✔
870
        min_value=1, label='Number of Rows', required=False,
871
        help_text='Number of offsets in the pattern in the RA direction (Grid only).')
872
    dither_num_columns = forms.IntegerField(
1✔
873
        min_value=1, label='Number of Columns', required=False,
874
        help_text='Number of offsets in the pattern in the declination direction (Grid only).')
875
    dither_center = forms.ChoiceField(
1✔
876
        choices=((True, 'True'), (False, 'False')),
877
        label='Center',
878
        required=False,
879
        help_text='If True, pattern is centered on initial target. Otherwise pattern begins at initial target.'
880
    )
881
    mosaic_pattern = forms.ChoiceField(
1✔
882
        choices=(('', 'None'), ('line', 'Line'), ('grid', 'Grid')),
883
        required=False,
884
        help_text="""Expand your Configurations with a set of different targets following a mosaic pattern.
885
                     Only works with Sidereal targets.
886
                  """
887
    )
888
    mosaic_num_points = forms.IntegerField(min_value=2, label='Number of Points',
1✔
889
                                           help_text='Number of Points in the pattern (Line only).', required=False)
890
    mosaic_point_overlap = forms.FloatField(
1✔
891
        label='Point Overlap Percent',
892
        help_text='Percentage overlap of pointings in the pattern as a percent of declination in FOV.',
893
        required=False, min_value=0.0, max_value=100.0)
894
    mosaic_line_overlap = forms.FloatField(
1✔
895
        label='Line Overlap Percent',
896
        help_text='Percentage overlap of pointings in the pattern as a percent of RA in FOV (Grid only).',
897
        required=False, min_value=0.0, max_value=100.0)
898
    mosaic_orientation = forms.FloatField(
1✔
899
        label='Orientation',
900
        help_text='Angular rotation of the pattern in degrees, measured clockwise East of North.',
901
        required=False, min_value=0.0)
902
    mosaic_num_rows = forms.IntegerField(
1✔
903
        min_value=1, label='Number of Rows',
904
        help_text='Number of pointings in the pattern in the declination direction (Grid only).', required=False)
905
    mosaic_num_columns = forms.IntegerField(
1✔
906
        min_value=1, label='Number of Columns',
907
        help_text='Number of pointings in the pattern in the RA direction (Grid only).', required=False)
908
    mosaic_center = forms.ChoiceField(
1✔
909
        choices=((True, 'True'), (False, 'False')),
910
        label='Center',
911
        required=False,
912
        help_text='If True, pattern is centered on initial target. Otherwise pattern begins at initial target.'
913
    )
914

915
    def __init__(self, *args, **kwargs):
1✔
916
        # Need to load the facility_settings here even though it gets loaded in super __init__
917
        # So that we can modify the initial data before hitting the base __init__
918
        self.facility_settings = kwargs.get('facility_settings', OCSSettings("OCS"))
1✔
919
        if 'initial' in kwargs:
1✔
920
            kwargs['initial'] = self.load_initial_from_template(kwargs['initial'])
×
921
        super().__init__(*args, **kwargs)
1✔
922
        for j in range(self.facility_settings.get_setting('max_configurations')):
1✔
923
            self.fields[f'c_{j+1}_instrument_type'] = forms.ChoiceField(
1✔
924
                choices=self.instrument_choices(), required=False,
925
                help_text=self.facility_settings.instrument_type_help,
926
                label='Instrument Type')
927
            self.fields[f'c_{j+1}_configuration_type'] = forms.ChoiceField(
1✔
928
                choices=self.configuration_type_choices(), required=False, label='Configuration Type')
929
            self.fields[f'c_{j+1}_repeat_duration'] = forms.FloatField(
1✔
930
                help_text=self.facility_settings.repeat_duration_help, required=False, label='Repeat Duration',
931
                widget=forms.TextInput(attrs={'placeholder': 'Seconds'}))
932
            self.fields[f'c_{j+1}_max_airmass'] = forms.FloatField(
1✔
933
                help_text=self.facility_settings.max_airmass_help, label='Max Airmass', min_value=0, initial=1.6,
934
                required=False)
935
            self.fields[f'c_{j+1}_min_lunar_distance'] = forms.IntegerField(
1✔
936
                min_value=0, label='Minimum Lunar Distance', required=False)
937
            self.fields[f'c_{j+1}_max_lunar_phase'] = forms.FloatField(
1✔
938
                help_text=self.facility_settings.max_lunar_phase_help, min_value=0, max_value=1.0,
939
                label='Maximum Lunar Phase', required=False)
940
            self.fields[f'c_{j+1}_target_override'] = forms.ChoiceField(
1✔
941
                choices=self.target_group_choices(),
942
                required=False,
943
                help_text='Set a different target for this configuration. Must be in the same target group.',
944
                label='Substitute Target for this Configuration'
945
            )
946
            for i in range(self.facility_settings.get_setting('max_instrument_configs')):
1✔
947
                self.fields[f'c_{j+1}_ic_{i+1}_exposure_count'] = forms.IntegerField(
1✔
948
                    min_value=1, label='Exposure Count', initial=1, required=False)
949
                self.fields[f'c_{j+1}_ic_{i+1}_exposure_time'] = forms.FloatField(
1✔
950
                    min_value=0.1, label='Exposure Time',
951
                    widget=forms.TextInput(attrs={'placeholder': 'Seconds'}),
952
                    help_text=self.facility_settings.exposure_time_help, required=False)
953
                for oe_group in self.get_optical_element_groups():
1✔
954
                    oe_group_plural = oe_group + 's'
1✔
955
                    self.fields[f'c_{j+1}_ic_{i+1}_{oe_group}'] = forms.ChoiceField(
1✔
956
                        choices=self.filter_choices_for_group(oe_group_plural), required=False,
957
                        label=oe_group.replace('_', ' ').capitalize())
958
        self.helper.layout = Layout(
1✔
959
            self.common_layout,
960
            self.layout(),
961
            self.button_layout()
962
        )
963
        if isinstance(self, CadenceForm):
1✔
964
            self.helper.layout.insert(2, self.cadence_layout())
×
965

966
    def form_name(self):
1✔
967
        return 'base'
×
968

969
    def instrument_config_layout_class(self):
1✔
970
        return OCSInstrumentConfigLayout
1✔
971

972
    def configuration_layout_class(self):
1✔
973
        return OCSConfigurationLayout
×
974

975
    def advanced_expansions_layout_class(self):
1✔
976
        return OCSAdvancedExpansionsLayout
1✔
977

978
    def layout(self):
1✔
979
        return Div(
1✔
980
            Div(
981
                Div(
982
                    'name',
983
                    css_class='col'
984
                ),
985
                Div(
986
                    'proposal',
987
                    css_class='col'
988
                ),
989
                css_class='form-row'
990
            ),
991
            Div(
992
                Div(
993
                    'observation_mode',
994
                    css_class='col'
995
                ),
996
                Div(
997
                    'ipp_value',
998
                    css_class='col'
999
                ),
1000
                css_class='form-row'
1001
            ),
1002
            Div(
1003
                Div(
1004
                    'optimization_type',
1005
                    css_class='col'
1006
                ),
1007
                Div(
1008
                    'configuration_repeats',
1009
                    css_class='col'
1010
                ),
1011
                css_class='form-row'
1012
            ),
1013
            Div(
1014
                Div(
1015
                    'start',
1016
                    css_class='col'
1017
                ),
1018
                Div(
1019
                    'end',
1020
                    css_class='col'
1021
                ),
1022
                css_class='form-row'
1023
            ),
1024
            self.configuration_layout_class()(
1025
                self.form_name(), self.facility_settings, self.instrument_config_layout_class(),
1026
                self.get_optical_element_groups()
1027
            ),
1028
            self.advanced_expansions_layout_class()(self.form_name(), self.facility_settings)
1029
        )
1030

1031
    def load_initial_from_template(self, initial):
1✔
1032
        """ Template data contains single fields like 'exposure_time' so convert those into the per config/ic versions
1033
        """
1034
        if not initial:
×
1035
            return initial
×
1036
        if 'template_name' in initial:
×
1037
            if 'exposure_time' in initial:
×
1038
                initial['c_1_ic_1_exposure_time'] = initial['exposure_time']
×
1039
            if 'exposure_count' in initial:
×
1040
                initial['c_1_ic_1_exposure_count'] = initial['exposure_count']
×
1041
            if 'max_airmass' in initial:
×
1042
                initial['c_1_max_airmass'] = initial['max_airmass']
×
1043
            if 'instrument_type' in initial:
×
1044
                initial['c_1_instrument_type'] = initial['instrument_type']
×
1045
            if 'filter' in initial:
×
1046
                for oe_group in self.get_optical_element_groups():
×
1047
                    oe_group_plural = oe_group + 's'
×
1048
                    filter_choices = self.filter_choices_for_group(oe_group_plural)
×
1049
                    if initial['filter'] in [f[0] for f in filter_choices]:
×
1050
                        initial[f'c_1_ic_1_{oe_group}'] = initial['filter']
×
1051
        return initial
×
1052

1053
    def _build_instrument_config(self, instrument_type, configuration_id, instrument_config_id):
1✔
1054
        # If the instrument config did not have an exposure time set, leave it out by returning None
1055
        if not self.cleaned_data.get(f'c_{configuration_id}_ic_{instrument_config_id}_exposure_time'):
1✔
1056
            return None
1✔
1057
        instrument_config = {
1✔
1058
            'exposure_count': self.cleaned_data[f'c_{configuration_id}_ic_{instrument_config_id}_exposure_count'],
1059
            'exposure_time': self.cleaned_data[f'c_{configuration_id}_ic_{instrument_config_id}_exposure_time'],
1060
            'optical_elements': {}
1061
        }
1062
        for oe_group in self.get_optical_element_groups():
1✔
1063
            instrument_config['optical_elements'][oe_group] = self.cleaned_data.get(
1✔
1064
                f'c_{configuration_id}_ic_{instrument_config_id}_{oe_group}')
1065

1066
        return instrument_config
1✔
1067

1068
    def _build_instrument_configs(self, instrument_type, configuration_id):
1✔
1069
        ics = []
1✔
1070
        for i in range(self.facility_settings.get_setting('max_instrument_configs')):
1✔
1071
            ic = self._build_instrument_config(instrument_type, configuration_id, i + 1)
1✔
1072
            # This will only include instrument configs with an exposure time set
1073
            if ic:
1✔
1074
                ics.append(ic)
1✔
1075

1076
        return ics
1✔
1077

1078
    def _build_configuration(self, build_id):
1✔
1079
        instrument_configs = self._build_instrument_configs(
1✔
1080
            self.cleaned_data[f'c_{build_id}_instrument_type'], build_id
1081
            )
1082
        # Check if the instrument configs are empty, and if so, leave this configuration out by returning None
1083
        if not instrument_configs:
1✔
1084
            return None
1✔
1085
        configuration = {
1✔
1086
            'type': self.cleaned_data[f'c_{build_id}_configuration_type'],
1087
            'instrument_type': self.cleaned_data[f'c_{build_id}_instrument_type'],
1088
            'instrument_configs': instrument_configs,
1089
            'acquisition_config': self._build_acquisition_config(build_id),
1090
            'guiding_config': self._build_guiding_config(build_id),
1091
            'constraints': {
1092
                'max_airmass': self.cleaned_data[f'c_{build_id}_max_airmass'],
1093
            }
1094
        }
1095
        if self.cleaned_data.get(f'c_{build_id}_target_override'):
1✔
1096
            configuration['target'] = self._build_target_fields(self.cleaned_data[f'c_{build_id}_target_override'])
×
1097
        else:
1098
            configuration['target'] = self._build_target_fields(self.cleaned_data['target_id'])
1✔
1099
        if self.cleaned_data.get(f'c_{build_id}_repeat_duration'):
1✔
1100
            configuration['repeat_duration'] = self.cleaned_data[f'c_{build_id}_repeat_duration']
×
1101
        if self.cleaned_data.get(f'c_{build_id}_min_lunar_distance'):
1✔
1102
            configuration['constraints']['min_lunar_distance'] = self.cleaned_data[f'c_{build_id}_min_lunar_distance']
×
1103
        if self.cleaned_data.get(f'c_{build_id}_max_lunar_phase'):
1✔
1104
            configuration['constraints']['max_lunar_phase'] = self.cleaned_data[f'c_{build_id}_max_lunar_phase']
×
1105

1106
        return configuration
1✔
1107

1108
    def _build_configurations(self):
1✔
1109
        configurations = []
1✔
1110
        for j in range(self.facility_settings.get_setting('max_configurations')):
1✔
1111
            configuration = self._build_configuration(j+1)
1✔
1112
            if configuration:
1✔
1113
                configurations.append(configuration)
1✔
1114

1115
        return configurations
1✔
1116

1117
    def _expand_dither_pattern(self, configuration):
1✔
1118
        payload = {
×
1119
            'configuration': configuration,
1120
            'pattern': self.cleaned_data.get('dither_pattern'),
1121
            'center': self.cleaned_data.get('dither_center')
1122
        }
1123
        if self.cleaned_data.get('dither_orientation'):
×
1124
            payload['orientation'] = self.cleaned_data['dither_orientation']
×
1125
        if self.cleaned_data.get('dither_point_spacing'):
×
1126
            payload['point_spacing'] = self.cleaned_data['dither_point_spacing']
×
1127
        if payload['pattern'] in ['line', 'spiral'] and self.cleaned_data.get('dither_num_points'):
×
1128
            payload['num_points'] = self.cleaned_data['dither_num_points']
×
1129
        if payload['pattern'] == 'grid':
×
1130
            if self.cleaned_data.get('dither_num_rows'):
×
1131
                payload['num_rows'] = self.cleaned_data['dither_num_rows']
×
1132
            if self.cleaned_data.get('dither_num_columns'):
×
1133
                payload['num_columns'] = self.cleaned_data['dither_num_columns']
×
1134
            if self.cleaned_data.get('dither_line_spacing'):
×
1135
                payload['line_spacing'] = self.cleaned_data['dither_line_spacing']
×
1136
        # Use the OCS Observation Portal dither pattern expansion to expand the configuration
1137
        response_json = {}
×
1138
        try:
×
1139
            response = make_request(
×
1140
                'POST',
1141
                urljoin(self.facility_settings.get_setting('portal_url'), '/api/configurations/dither/'),
1142
                json=payload,
1143
                headers={'Authorization': 'Token {0}'.format(self.facility_settings.get_setting('api_key'))}
1144
            )
1145
            response_json = response.json()
×
1146
            response.raise_for_status()
×
1147
            return response_json
×
1148
        except Exception:
×
1149
            logger.warning(f"Error expanding dither pattern: {response_json}")
×
1150
            return configuration
×
1151

1152
    def _expand_mosaic_pattern(self, request):
1✔
1153
        payload = {
×
1154
            'request': request,
1155
            'pattern': self.cleaned_data.get('mosaic_pattern'),
1156
            'center': self.cleaned_data.get('mosaic_center')
1157
        }
1158
        if self.cleaned_data.get('mosaic_orientation'):
×
1159
            payload['orientation'] = self.cleaned_data['mosaic_orientation']
×
1160
        if self.cleaned_data.get('mosaic_point_overlap'):
×
1161
            payload['point_overlap_percent'] = self.cleaned_data['mosaic_point_overlap']
×
1162
        if payload['pattern'] == 'line' and self.cleaned_data.get('mosaic_num_points'):
×
1163
            payload['num_points'] = self.cleaned_data['mosaic_num_points']
×
1164
        if payload['pattern'] == 'grid':
×
1165
            if self.cleaned_data.get('mosaic_num_rows'):
×
1166
                payload['num_rows'] = self.cleaned_data['mosaic_num_rows']
×
1167
            if self.cleaned_data.get('mosaic_num_columns'):
×
1168
                payload['num_columns'] = self.cleaned_data['mosaic_num_columns']
×
1169
            if self.cleaned_data.get('mosaic_line_overlap'):
×
1170
                payload['line_overlap_percent'] = self.cleaned_data['mosaic_line_overlap']
×
1171

1172
        # Use the OCS Observation Portal dither pattern expansion to expand the configuration
1173
        response_json = {}
×
1174
        try:
×
1175
            response = make_request(
×
1176
                'POST',
1177
                urljoin(self.facility_settings.get_setting('portal_url'), '/api/requests/mosaic/'),
1178
                json=payload,
1179
                headers={'Authorization': 'Token {0}'.format(self.facility_settings.get_setting('api_key'))}
1180
            )
1181
            response_json = response.json()
×
1182
            response.raise_for_status()
×
1183
            return response_json
×
1184
        except Exception:
×
1185
            logger.warning(f"Error expanding mosaic pattern: {response_json}")
×
1186
            return request
×
1187

1188
    def _build_location(self, configuration_id=1):
1✔
1189
        return {
1✔
1190
            'telescope_class': self._get_instruments()[
1191
                self.cleaned_data[f'c_{configuration_id}_instrument_type']]['class']
1192
        }
1193

1194
    def observation_payload(self):
1✔
1195
        payload = {
1✔
1196
            'name': self.cleaned_data['name'],
1197
            'proposal': self.cleaned_data['proposal'],
1198
            'ipp_value': self.cleaned_data['ipp_value'],
1199
            'operator': 'SINGLE',
1200
            'observation_type': self.cleaned_data['observation_mode'],
1201
            'requests': [
1202
                {
1203
                    'optimization_type': self.cleaned_data['optimization_type'],
1204
                    'configuration_repeats': self.cleaned_data['configuration_repeats'],
1205
                    'configurations': self._build_configurations(),
1206
                    'windows': [
1207
                        {
1208
                            'start': self.cleaned_data['start'],
1209
                            'end': self.cleaned_data['end']
1210
                        }
1211
                    ],
1212
                    'location': self._build_location()
1213
                }
1214
            ]
1215
        }
1216
        if (self.cleaned_data.get('dither_pattern') and self.cleaned_data.get('dither_point_spacing') and len(
1✔
1217
                payload['requests'][0]['configurations']) == 1):
1218
            payload['requests'][0]['configurations'][0] = self._expand_dither_pattern(
×
1219
                payload['requests'][0]['configurations'][0])
1220
        if self.cleaned_data.get('mosaic_pattern') and len(payload['requests'][0]['configurations']) == 1:
1✔
1221
            payload['requests'][0] = self._expand_mosaic_pattern(payload['requests'][0])
×
1222
        if self.cleaned_data.get('period') and self.cleaned_data.get('jitter') is not None:
1✔
1223
            payload = self._expand_cadence_request(payload)
×
1224

1225
        return payload
1✔
1226

1227

1228
class OCSFacility(BaseRoboticObservationFacility):
1✔
1229
    """
1230
    The ``OCSFacility`` is the interface to an OCS Observation Portal. For information regarding
1231
    OCS observing and the available parameters, please see https://observatorycontrolsystem.github.io/.
1232
    """
1233
    name = 'OCS'
1✔
1234
    observation_forms = {
1✔
1235
        'ALL': OCSFullObservationForm,
1236
    }
1237

1238
    def __init__(self, facility_settings=OCSSettings('OCS')):
1✔
1239
        self.facility_settings = facility_settings
1✔
1240
        super().__init__()
1✔
1241

1242
    # TODO: this should be called get_form_class
1243
    def get_form(self, observation_type):
1✔
1244
        return self.observation_forms.get(observation_type, OCSFullObservationForm)
×
1245

1246
    # TODO: this should be called get_template_form_class
1247
    def get_template_form(self, observation_type):
1✔
1248
        return OCSTemplateBaseForm
×
1249

1250
    def submit_observation(self, observation_payload):
1✔
1251
        response = make_request(
×
1252
            'POST',
1253
            urljoin(self.facility_settings.get_setting('portal_url'), '/api/requestgroups/'),
1254
            json=observation_payload,
1255
            headers=self._portal_headers()
1256
        )
1257
        return [r['id'] for r in response.json()['requests']]
×
1258

1259
    def validate_observation(self, observation_payload):
1✔
1260
        response = make_request(
×
1261
            'POST',
1262
            urljoin(self.facility_settings.get_setting('portal_url'), '/api/requestgroups/validate/'),
1263
            json=observation_payload,
1264
            headers=self._portal_headers()
1265
        )
1266
        return response.json()
×
1267

1268
    def cancel_observation(self, observation_id):
1✔
1269
        requestgroup_id = self._get_requestgroup_id(observation_id)
×
1270

1271
        response = make_request(
×
1272
            'POST',
1273
            urljoin(self.facility_settings.get_setting('portal_url'), f'/api/requestgroups/{requestgroup_id}/cancel/'),
1274
            headers=self._portal_headers()
1275
        )
1276

1277
        return response.json()['state'] == 'CANCELED'
×
1278

1279
    def get_observation_url(self, observation_id):
1✔
1280
        return urljoin(self.facility_settings.get_setting('portal_url'), f'/requests/{observation_id}')
×
1281

1282
    def get_flux_constant(self):
1✔
1283
        return self.facility_settings.get_data_flux_constant()
×
1284

1285
    def get_wavelength_units(self):
1✔
1286
        return self.facility_settings.get_data_wavelength_units()
×
1287

1288
    def get_date_obs_from_fits_header(self, header):
1✔
1289
        return header.get(self.facility_settings.get_fits_header_dateobs_keyword(), None)
×
1290

1291
    def is_fits_facility(self, header):
1✔
1292
        """
1293
        Returns True if the keyword is in the given FITS header and contains the value specified, False
1294
        otherwise.
1295

1296
        :param header: FITS header object
1297
        :type header: dictionary-like
1298

1299
        :returns: True if header matches your OCS facility, False otherwise
1300
        :rtype: boolean
1301
        """
1302
        return (self.facility_settings.get_fits_facility_header_value() == header.get(
×
1303
                self.facility_settings.get_fits_facility_header_keyword(), None))
1304

1305
    def get_start_end_keywords(self):
1✔
1306
        return ('start', 'end')
1✔
1307

1308
    def get_terminal_observing_states(self):
1✔
1309
        return self.facility_settings.get_terminal_observing_states()
1✔
1310

1311
    def get_failed_observing_states(self):
1✔
1312
        return self.facility_settings.get_failed_observing_states()
1✔
1313

1314
    def get_observing_sites(self):
1✔
1315
        return self.facility_settings.get_sites()
1✔
1316

1317
    def get_facility_weather_urls(self):
1✔
1318
        """
1319
        `facility_weather_urls = {'code': 'XYZ', 'sites': [ site_dict, ... ]}`
1320
        where
1321
        `site_dict = {'code': 'XYZ', 'weather_url': 'http://path/to/weather'}`
1322
        """
1323
        return self.facility_settings.get_weather_urls()
×
1324

1325
    def get_facility_status(self):
1✔
1326
        """
1327
        Get the telescope_states from the OCS API endpoint and simply
1328
        transform the returned JSON into the following dictionary hierarchy
1329
        for use by the facility_status.html template partial.
1330

1331
        facility_dict = {'code': 'OCS', 'sites': [ site_dict, ... ]}
1332
        site_dict = {'code': 'XYZ', 'telescopes': [ telescope_dict, ... ]}
1333
        telescope_dict = {'code': 'XYZ', 'status': 'AVAILABILITY'}
1334

1335
        Here's an example of the returned dictionary:
1336

1337
        literal_facility_status_example = {
1338
            'code': 'OCS',
1339
            'sites': [
1340
                {
1341
                    'code': 'BPL',
1342
                    'telescopes': [
1343
                        {
1344
                            'code': 'bpl.doma.1m0a',
1345
                            'status': 'AVAILABLE'
1346
                        },
1347
                    ],
1348
                },
1349
                {
1350
                    'code': 'ELP',
1351
                    'telescopes': [
1352
                        {
1353
                            'code': 'elp.doma.1m0a',
1354
                            'status': 'AVAILABLE'
1355
                        },
1356
                        {
1357
                            'code': 'elp.domb.1m0a',
1358
                            'status': 'AVAILABLE'
1359
                        },
1360
                    ]
1361
                }
1362
            ]
1363
        }
1364

1365
        :return: facility_dict
1366
        """
1367
        # make the request to the OCS API for the telescope_states
1368
        now = datetime.now()
1✔
1369
        telescope_states = {}
1✔
1370
        try:
1✔
1371
            response = make_request(
1✔
1372
                'GET',
1373
                urljoin(self.facility_settings.get_setting('portal_url'), '/api/telescope_states/'),
1374
                headers=self._portal_headers()
1375
            )
1376
            response.raise_for_status()
1✔
1377
            telescope_states = response.json()
×
1378
            logger.info(f"Telescope states took {(datetime.now() - now).total_seconds()}")
×
1379
        except (requests.exceptions.HTTPError, requests.exceptions.ConnectionError) as e:
1✔
1380
            logger.warning(f"Error retrieving telescope_states from OCS for facility status: {repr(e)}")
1✔
1381
        # Now, transform the telescopes_state dictionary in a dictionary suitable
1382
        # for the facility_status.html template partial.
1383

1384
        # set up the return value to be populated by the for loop below
1385
        facility_status = {
1✔
1386
            'code': self.name,
1387
            'sites': []
1388
        }
1389
        site_list = [site["sitecode"] for site in self.get_observing_sites().values()]
1✔
1390

1391
        for telescope_key, telescope_value in telescope_states.items():
1✔
1392
            [site_code, _, _] = telescope_key.split('.')
×
1393

1394
            # limit returned sites to those provided by the facility
1395
            if site_code in site_list:
×
1396

1397
                # extract this telescope and it's status from the response
1398
                telescope = {
×
1399
                    'code': telescope_key,
1400
                    'status': telescope_value[0]['event_type']
1401
                }
1402

1403
                # get the site dictionary from the facilities list of sites
1404
                # filter by site_code and provide a default (None) for new sites
1405
                site = next((site_ix for site_ix in facility_status['sites']
×
1406
                             if site_ix['code'] == site_code), None)
1407
                # create the site if it's new and not yet in the facility_status['sites] list
1408
                if site is None:
×
1409
                    new_site = {
×
1410
                        'code': site_code,
1411
                        'telescopes': []
1412
                    }
1413
                    facility_status['sites'].append(new_site)
×
1414
                    site = new_site
×
1415

1416
                # Now, add the telescope to the site's telescopes
1417
                site['telescopes'].append(telescope)
×
1418

1419
        return facility_status
1✔
1420

1421
    def get_observation_status(self, observation_id):
1✔
1422
        response = make_request(
×
1423
            'GET',
1424
            urljoin(self.facility_settings.get_setting('portal_url'), f'/api/requests/{observation_id}'),
1425
            headers=self._portal_headers()
1426
        )
1427
        state = response.json()['state']
×
1428

1429
        response = make_request(
×
1430
            'GET',
1431
            urljoin(self.facility_settings.get_setting('portal_url'), f'/api/requests/{observation_id}/observations/'),
1432
            headers=self._portal_headers()
1433
        )
1434
        blocks = response.json()
×
1435
        current_block = None
×
1436
        for block in blocks:
×
1437
            if block['state'] == 'COMPLETED':
×
1438
                current_block = block
×
1439
                break
×
1440
            elif block['state'] == 'PENDING':
×
1441
                current_block = block
×
1442
        if current_block:
×
1443
            scheduled_start = current_block['start']
×
1444
            scheduled_end = current_block['end']
×
1445
        else:
1446
            scheduled_start, scheduled_end = None, None
×
1447

1448
        return {'state': state, 'scheduled_start': scheduled_start, 'scheduled_end': scheduled_end}
×
1449

1450
    def data_products(self, observation_id, product_id=None):
1✔
1451
        products = []
×
1452
        for frame in self._archive_frames(observation_id, product_id):
×
1453
            products.append({
×
1454
                'id': frame['id'],
1455
                'filename': frame['filename'],
1456
                'created': parse(frame['DATE_OBS']),
1457
                'url': frame['url']
1458
            })
1459
        return products
×
1460

1461
    # The following methods are used internally by this module
1462
    # and should not be called directly from outside code.
1463

1464
    def _portal_headers(self):
1✔
1465
        if self.facility_settings.get_setting('api_key'):
1✔
1466
            return {'Authorization': f'Token {self.facility_settings.get_setting("api_key")}'}
×
1467
        else:
1468
            return {}
1✔
1469

1470
    def _get_requestgroup_id(self, observation_id):
1✔
1471
        query_params = urlencode({'request_id': observation_id})
1✔
1472

1473
        response = make_request(
1✔
1474
            'GET',
1475
            urljoin(self.facility_settings.get_setting('portal_url'), f'/api/requestgroups?{query_params}'),
1476
            headers=self._portal_headers()
1477
        )
1478
        requestgroups = response.json()
1✔
1479

1480
        if requestgroups['count'] == 1:
1✔
1481
            return requestgroups['results'][0]['id']
1✔
1482

1483
    def _archive_frames(self, observation_id, product_id=None):
1✔
1484
        frames = []
×
1485
        if product_id:
×
1486
            response = make_request(
×
1487
                'GET',
1488
                urljoin(self.facility_settings.get_setting('archive_url'), f'/frames/{product_id}/'),
1489
                headers=self._portal_headers()
1490
            )
1491
            frames = [response.json()]
×
1492
        else:
1493
            url = urljoin(self.facility_settings.get_setting('archive_url'),
×
1494
                          f'/frames/?REQNUM={observation_id}&limit=1000')
1495
            while url:
×
1496
                response = make_request(
×
1497
                    'GET',
1498
                    url,
1499
                    headers=self._portal_headers()
1500
                )
1501
                frames.extend(response.json()['results'])
×
1502
                url = response.json()['next']
×
1503
        return frames
×
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