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

observatorycontrolsystem / observation-portal / 26670527295

30 May 2026 01:21AM UTC coverage: 96.449% (+0.001%) from 96.448%
26670527295

push

github

Jon
Added SKY type.

9 of 9 new or added lines in 1 file covered. (100.0%)

5 existing lines in 1 file now uncovered.

36208 of 37541 relevant lines covered (96.45%)

1.93 hits per line

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

94.52
/observation_portal/requestgroups/models.py
1
from django.db import models
1✔
2
from django.contrib.auth.models import User
2✔
3
from django.utils.functional import cached_property
2✔
4
from django.core.validators import MinValueValidator, MaxValueValidator
2✔
5
from django.core.cache import cache
2✔
6
from django.utils import timezone
2✔
7
from django.urls import reverse
2✔
8
from django.forms.models import model_to_dict
2✔
9
from django.utils.module_loading import import_string
2✔
10
from django.conf import settings
2✔
11
import logging
2✔
12

13
from observation_portal.common.configdb import configdb
2✔
14
from observation_portal.proposals.models import Proposal, TimeAllocationKey
2✔
15
from observation_portal.requestgroups.target_helpers import TARGET_TYPE_HELPER_MAP
2✔
16
from observation_portal.common.rise_set_utils import get_rise_set_target
2✔
17
from observation_portal.requestgroups.duration_utils import (
2✔
18
    get_total_request_duration,
19
    get_configuration_duration,
20
    get_optical_change_duration,
21
    get_total_complete_configurations_duration,
22
    get_total_duration_dict,
23
    get_semester_in
24
)
25

26
logger = logging.getLogger(__name__)
2✔
27

28

29
def requestgroup_as_dict(instance):
2✔
30
    ret_dict = model_to_dict(instance)
2✔
31
    ret_dict['created'] = instance.created
2✔
32
    ret_dict['modified'] = instance.modified
2✔
33
    ret_dict['submitter'] = instance.submitter.username
2✔
34
    ret_dict['proposal'] = instance.proposal.id
2✔
35
    ret_dict['requests'] = [r.as_dict() for r in instance.requests.all()]
2✔
36
    return ret_dict
2✔
37

38

39
def request_as_dict(instance, for_observation=False):
2✔
40
    ret_dict = model_to_dict(instance, exclude=instance.SERIALIZER_EXCLUDE)
2✔
41
    ret_dict['modified'] = instance.modified
2✔
42
    ret_dict['duration'] = instance.duration
2✔
43
    ret_dict['configurations'] = [c.as_dict() for c in instance.configurations.all()]
2✔
44
    if not for_observation:
2✔
45
        if instance.request_group.observation_type in RequestGroup.NON_SCHEDULED_TYPES:
2✔
46
            if instance.observation_set.count() > 0:
×
47
                observation = instance.observation_set.first()
×
48
                ret_dict['location'] = {'site': observation.site, 'enclosure': observation.enclosure,
×
49
                                        'telescope': observation.telescope}
50
                ret_dict['windows'] = [{'start': observation.start, 'end': observation.end}]
×
51
        else:
52
            ret_dict['location'] = instance.location.as_dict() if hasattr(instance, 'location') else {}
2✔
53
            ret_dict['windows'] = [w.as_dict() for w in instance.windows.all()]
2✔
54
    return ret_dict
2✔
55

56

57
def location_as_dict(instance):
2✔
58
    ret_dict = model_to_dict(instance, exclude=instance.SERIALIZER_EXCLUDE)
2✔
59
    ret_dict = {field: value for field, value in ret_dict.items() if value}
2✔
60
    return ret_dict
2✔
61

62

63
def window_as_dict(instance):
2✔
64
    return model_to_dict(instance, exclude=instance.SERIALIZER_EXCLUDE)
2✔
65

66

67
def configuration_as_dict(instance):
2✔
68
    cdict = model_to_dict(instance, exclude=instance.SERIALIZER_EXCLUDE)
2✔
69
    cdict['instrument_configs'] = [ic.as_dict() for ic in instance.instrument_configs.all()]
2✔
70
    cdict['constraints'] = instance.constraints.as_dict()
2✔
71
    cdict['acquisition_config'] = instance.acquisition_config.as_dict()
2✔
72
    cdict['guiding_config'] = instance.guiding_config.as_dict()
2✔
73
    try:
2✔
74
        cdict['target'] = instance.target.as_dict()
2✔
75
    except Exception:
2✔
76
        cdict['target'] = {}
2✔
77
    return cdict
2✔
78

79

80
def target_as_dict(instance):
2✔
81
    ret_dict = model_to_dict(instance, exclude=instance.SERIALIZER_EXCLUDE)
2✔
82
    extra_params = ret_dict.get('extra_params', {})
2✔
83
    target_helper = TARGET_TYPE_HELPER_MAP[ret_dict['type'].upper()](ret_dict)
2✔
84
    ret_dict = {k: ret_dict.get(k) for k in target_helper.fields}
2✔
85
    ret_dict['extra_params'] = extra_params
2✔
86
    return ret_dict
2✔
87

88

89
def instrumentconfig_as_dict(instance):
2✔
90
    ic = model_to_dict(instance, exclude=instance.SERIALIZER_EXCLUDE)
2✔
91
    ic['rois'] = [roi.as_dict() for roi in instance.rois.all()]
2✔
92
    return ic
2✔
93

94

95
def regionofinterest_as_dict(instance):
2✔
96
    return model_to_dict(instance, exclude=instance.SERIALIZER_EXCLUDE)
2✔
97

98

99
def guidingconfig_as_dict(instance):
2✔
100
    return model_to_dict(instance, exclude=instance.SERIALIZER_EXCLUDE)
2✔
101

102

103
def acquisitionconfig_as_dict(instance):
2✔
104
    config = model_to_dict(instance, exclude=instance.SERIALIZER_EXCLUDE)
2✔
105
    config = {field: value for field, value in config.items() if value is not None}
2✔
106
    return config
2✔
107

108

109
def constraints_as_dict(instance):
2✔
110
    constraints = model_to_dict(instance, exclude=instance.SERIALIZER_EXCLUDE)
2✔
111
    constraints = {field: value for field, value in constraints.items() if value is not None}
2✔
112
    return constraints
2✔
113

114

115
class RequestGroup(models.Model):
2✔
116
    NORMAL = 'NORMAL'
2✔
117
    RAPID_RESPONSE = 'RAPID_RESPONSE'
2✔
118
    TIME_CRITICAL = 'TIME_CRITICAL'
2✔
119
    DIRECT = 'DIRECT'
2✔
120
    REAL_TIME = 'REAL_TIME'
2✔
121
    NON_SCHEDULED_TYPES = [DIRECT, REAL_TIME]
2✔
122

123
    STATE_CHOICES = (
2✔
124
        ('PENDING', 'PENDING'),
125
        ('COMPLETED', 'COMPLETED'),
126
        ('WINDOW_EXPIRED', 'WINDOW_EXPIRED'),
127
        ('FAILURE_LIMIT_REACHED', 'FAILURE_LIMIT_REACHED'),
128
        ('CANCELED', 'CANCELED'),
129
    )
130

131
    OPERATOR_CHOICES = (
2✔
132
        ('SINGLE', 'SINGLE'),
133
        ('MANY', 'MANY'),
134
    )
135

136
    OBSERVATION_TYPES = (
2✔
137
        ('NORMAL', NORMAL),
138
        ('RAPID_RESPONSE', RAPID_RESPONSE),
139
        ('TIME_CRITICAL', TIME_CRITICAL),
140
        ('DIRECT', DIRECT),
141
        ('REAL_TIME', REAL_TIME)
142
    )
143

144
    submitter = models.ForeignKey(
2✔
145
        User, on_delete=models.CASCADE,
146
        help_text='The user that submitted this RequestGroup'
147
    )
148
    proposal = models.ForeignKey(
2✔
149
        Proposal, on_delete=models.CASCADE,
150
        help_text='The Proposal under which the observations for this RequestGroup are made'
151
    )
152
    name = models.CharField(
2✔
153
        max_length=50,
154
        help_text='Descriptive name for this RequestGroup. This string will be placed in the FITS header as the '
155
                  'GROUPID keyword value for all FITS frames originating from this RequestGroup.'
156
    )
157
    observation_type = models.CharField(
2✔
158
        max_length=40, choices=OBSERVATION_TYPES,
159
        help_text='The type of observations under this RequestGroup. Requests submitted with RAPID_RESPONSE '
160
                  'bypass normal scheduling and are executed immediately. Requests submitted with TIME_CRITICAL are '
161
                  'scheduled normally but with a high priority. These modes are only available if the Proposal was '
162
                  'granted special time. More information is located '
163
                  '<a href="https://lco.global/documentation/special-scheduling-modes/">here</a>.'
164
    )
165
    operator = models.CharField(
2✔
166
        max_length=20, choices=OPERATOR_CHOICES,
167
        help_text='Operator that describes how child Requests are scheduled. Use SINGLE if you have only one Request '
168
                  'and MANY if you have more than one.'
169
    )
170
    ipp_value = models.FloatField(
2✔
171
        validators=[MinValueValidator(0.0)],
172
        help_text='A multiplier to the base priority of the Proposal for this RequestGroup and all child Requests. '
173
                  'A value > 1.0 will raise the priority and debit the Proposal ipp_time_available upon submission. '
174
                  'If a Request does not complete, the time debited for that Request is returned. A value < 1.0 will '
175
                  'lower the priority and credit the ipp_time_available of the Proposal up to the ipp_limit on the '
176
                  'successful completion of a Request. The value is generally set to 1.05. More information can be '
177
                  'found <a href="https://lco.global/files/User_Documentation/the_new_priority_factor.pdf">here</a>.'
178
    )
179
    created = models.DateTimeField(
2✔
180
        auto_now_add=True, db_index=True,
181
        help_text='Time when this RequestGroup was created'
182
    )
183
    state = models.CharField(
2✔
184
        max_length=40, choices=STATE_CHOICES, default=STATE_CHOICES[0][0], db_index=True,
185
        help_text='Current state of this RequestGroup'
186
    )
187
    modified = models.DateTimeField(
2✔
188
        auto_now=True, db_index=True,
189
        help_text='Time when this RequestGroup was last changed'
190
    )
191

192
    class Meta:
2✔
193
        ordering = ('-created',)
2✔
194

195
    def __str__(self):
2✔
196
        return self.get_id_display()
2✔
197

198
    def get_id_display(self):
2✔
199
        return str(self.id)
2✔
200

201
    def get_absolute_url(self):
2✔
202
        return reverse('api:request_groups-detail', kwargs={'pk': self.pk})
×
203

204
    def as_dict(self):
2✔
205
        return import_string(settings.AS_DICT['requestgroups']['RequestGroup'])(self)
2✔
206

207
    @property
2✔
208
    def min_window_time(self):
2✔
209
        return min([request.min_window_time for request in self.requests.all()])
×
210

211
    @property
2✔
212
    def max_window_time(self):
2✔
213
        return max([request.max_window_time for request in self.requests.all()])
2✔
214

215
    @property
2✔
216
    def timeallocations(self):
2✔
217
        return self.proposal.timeallocation_set.filter(
×
218
            semester__start__lte=self.min_window_time,
219
            semester__end__gte=self.max_window_time,
220
        )
221

222
    @property
2✔
223
    def total_duration(self):
2✔
224
        cached_duration = cache.get('requestgroup_duration_{}'.format(self.id))
2✔
225
        if not cached_duration:
2✔
226
            duration = get_total_duration_dict(self.as_dict())
2✔
227
            cache.set('requestgroup_duration_{}'.format(self.id), duration, 86400 * 30 * 6)
2✔
228
            return duration
2✔
229
        else:
230
            return cached_duration
×
231

232

233
class Request(models.Model):
2✔
234
    STATE_CHOICES = (
2✔
235
        ('PENDING', 'PENDING'),
236
        ('COMPLETED', 'COMPLETED'),
237
        ('WINDOW_EXPIRED', 'WINDOW_EXPIRED'),
238
        ('FAILURE_LIMIT_REACHED', 'FAILURE_LIMIT_REACHED'),
239
        ('CANCELED', 'CANCELED'),
240
    )
241

242
    OPTIMIZATION_TYPES = (
2✔
243
        ('TIME', 'TIME'),
244
        ('AIRMASS', 'AIRMASS'),
245
    )
246

247
    SERIALIZER_EXCLUDE = ('request_group',)
2✔
248

249
    request_group = models.ForeignKey(
2✔
250
        RequestGroup, related_name='requests', on_delete=models.CASCADE,
251
        help_text='The RequestGroup to which this Request belongs'
252
    )
253
    observation_note = models.CharField(
2✔
254
        max_length=255, default='', blank=True,
255
        help_text='Text describing this Request'
256
    )
257
    optimization_type = models.CharField(
2✔
258
        max_length=40,
259
        choices=OPTIMIZATION_TYPES,
260
        default=OPTIMIZATION_TYPES[0][0],
261
        help_text='Optimization emphasis, either TIME for as soon as possible, or AIRMASS for best airmass'
262
    )
263
    state = models.CharField(
2✔
264
        max_length=40, choices=STATE_CHOICES, default=STATE_CHOICES[0][0],
265
        help_text='Current state of this Request'
266
    )
267
    suspend_until = models.DateTimeField(
2✔
268
        null=True, blank=True,
269
        help_text='Timestamp to suspend scheduling until for this Request. If it is > now, this request will not be considered for scheduling.'
270
    )
271
    modified = models.DateTimeField(
2✔
272
        auto_now=True, db_index=True,
273
        help_text='Time at which this Request last changed'
274
    )
275
    created = models.DateTimeField(auto_now_add=True, help_text='Time at which the Request was created')
2✔
276

277
    # Minimum completable observation threshold
278
    acceptability_threshold = models.FloatField(
2✔
279
        default=90.0, validators=[MinValueValidator(0.0), MaxValueValidator(100.0)],
280
        help_text='The percentage of this Request that must be completed to mark it as complete and avert '
281
                  'rescheduling. The percentage should be set to the lowest value for which the amount of data is '
282
                  'acceptable to meet the science goal of the Request. Defaults to 100 for FLOYDS observations and '
283
                  '90 for all other observations.'
284
    )
285
    configuration_repeats = models.PositiveIntegerField(
2✔
286
        default=1, validators=[MinValueValidator(1)],
287
        help_text='The number of times to repeat the set of Configurations in this Request. This is normally used '
288
                  'to nod between two or more separate Targets. This field must be set to a value greater than 0.'
289
    )
290
    extra_params = models.JSONField(
2✔
291
        default=dict,
292
        blank=True,
293
        verbose_name='extra parameters',
294
        help_text='Extra Request parameters'
295
    )
296

297
    class Meta:
2✔
298
        ordering = ('id',)
2✔
299

300
    def __str__(self):
2✔
301
        return self.get_id_display()
×
302

303
    def get_id_display(self):
2✔
304
        return str(self.id)
×
305

306
    def as_dict(self, for_observation=False):
2✔
307
        return import_string(settings.AS_DICT['requestgroups']['Request'])(self, for_observation=for_observation)
2✔
308

309
    @cached_property
2✔
310
    def duration(self):
2✔
311
        cached_duration = cache.get('request_duration_{}'.format(self.id))
2✔
312
        if not cached_duration:
2✔
313
            duration = get_total_request_duration({'configurations': [c.as_dict() for c in self.configurations.all()],
2✔
314
                                                  'windows': [w.as_dict() for w in self.windows.all()],
315
                                                  'configuration_repeats': self.configuration_repeats})
316
            cache.set('request_duration_{}'.format(self.id), duration, 86400 * 30 * 6)
2✔
317
            return duration
2✔
318
        else:
319
            return cached_duration
×
320

321
    @property
2✔
322
    def min_window_time(self):
2✔
323
        return min([window.start for window in self.windows.all()])
2✔
324

325
    @property
2✔
326
    def max_window_time(self):
2✔
327
        return max([window.end for window in self.windows.all()])
2✔
328

329
    @property
2✔
330
    def semester(self):
2✔
331
        # Get the semester that contains the request windows. This should return a semester since requests are validated on
332
        # submission to have all windows be in the same semester, but some old requests might have windows that span multiple semesters.
333
        semester = get_semester_in(self.min_window_time, self.max_window_time)
2✔
334
        # If the request windows do not fit within any single semester, use the semester that contains
335
        # the start time of any of the associated observations.
336
        if semester is None:
2✔
337
            observation = self.observation_set.first()
2✔
338
            if observation:
2✔
339
                semester = get_semester_in(observation.start, observation.start)
2✔
340
        # Fall back to using the semester that contains the first window start time.
341
        if semester is None:
2✔
342
            semester = get_semester_in(self.min_window_time, self.min_window_time)
2✔
343
        return semester
2✔
344

345
    @property
2✔
346
    def time_allocation_keys(self):
2✔
347
        taks = set()
2✔
348
        for config in self.configurations.all():
2✔
349
            taks.add(TimeAllocationKey(self.semester.id, config.instrument_type))
2✔
350
        return list(taks)
2✔
351

352
    @property
2✔
353
    def timeallocations(self):
2✔
354
        return self.request_group.proposal.timeallocation_set.filter(
2✔
355
            semester__start__lte=self.min_window_time,
356
            semester__end__gte=self.max_window_time,
357
            instrument_types__overlap=list({conf.instrument_type for conf in self.configurations.all()})
358
        )
359

360
    def get_remaining_duration(self, configurations_after_priority, include_current=False, current_repeat=1):
2✔
361
        request_dict = self.as_dict()
2✔
362
        start_time = (min([window['start'] for window in request_dict['windows']])
2✔
363
                      if 'windows' in request_dict and request_dict['windows'] else timezone.now())
364
        try:
2✔
365
            configurations = sorted(request_dict['configurations'], key=lambda x: x['priority'])
2✔
366
        except KeyError:
×
367
            configurations = request_dict['configurations']
×
368
        duration = get_total_complete_configurations_duration(
2✔
369
            configurations,
370
            start_time,
371
            configurations_after_priority
372
        )
373
        if self.configuration_repeats > current_repeat:
2✔
374
            # We have configuration repeats left, so add multiples of the total configuration duration
375
            config_duration = get_total_complete_configurations_duration(
2✔
376
                configurations,
377
                start_time
378
            )
379
            repeats_left = self.configuration_repeats - current_repeat
2✔
380
            duration += (repeats_left * config_duration)
2✔
381
        if include_current:
2✔
382
            previous_optical_elements = {}
2✔
383
            for configuration_dict in configurations:
2✔
384
                if configuration_dict['priority'] == configurations_after_priority:
2✔
385
                    request_overheads = configdb.get_request_overheads(configuration_dict['instrument_type'])
2✔
386
                    # Add the exposure overhead for the current configuration (no front padding)
387
                    duration += get_configuration_duration(configuration_dict, request_overheads, include_front_padding=False)['duration']
2✔
388
                    # Add in the optical element change overhead for any changes within this configuration
389
                    duration += get_optical_change_duration(configuration_dict, request_overheads, previous_optical_elements)
2✔
390
                    break
2✔
391
                else:
392
                    previous_optical_elements = configuration_dict['instrument_configs'][-1].get('optical_elements', {})
2✔
393

394
        return duration
2✔
395

396

397
class Location(models.Model):
2✔
398
    SERIALIZER_EXCLUDE = ('request', 'id')
2✔
399

400
    request = models.OneToOneField(
2✔
401
        Request, on_delete=models.CASCADE,
402
        help_text='The Request to which this Location applies'
403
    )
404
    telescope_class = models.CharField(
2✔
405
        max_length=20,
406
        help_text='The telescope class on which to observe the Request. The class describes the aperture size, '
407
                  'e.g. 1m0 is a 1m telescope, and 0m4 is a 0.4m telescope.'
408
    )
409
    site = models.CharField(
2✔
410
        max_length=20, default='', blank=True,
411
        help_text='Three-letter site code indicating the site at which to observe the Request'
412
    )
413
    enclosure = models.CharField(
2✔
414
        max_length=20, default='', blank=True,
415
        help_text='Four-letter enclosure code indicating the enclosure from which to observe the Request'
416
    )
417
    telescope = models.CharField(
2✔
418
        max_length=20, default='', blank=True,
419
        help_text='Four-letter telescope code indicating the telescope on which to observe the Request'
420
    )
421

422
    class Meta:
2✔
423
        ordering = ('id',)
2✔
424

425
    def as_dict(self):
2✔
426
        return import_string(settings.AS_DICT['requestgroups']['Location'])(self)
2✔
427

428
    def __str__(self):
2✔
429
        return '{}.{}.{}'.format(self.site, self.enclosure, self.telescope)
×
430

431

432
class Window(models.Model):
2✔
433
    SERIALIZER_EXCLUDE = ('request', 'id')
2✔
434

435
    request = models.ForeignKey(
2✔
436
        Request, related_name='windows', on_delete=models.CASCADE,
437
        help_text='The Request to which this Window applies'
438
    )
439
    start = models.DateTimeField(
2✔
440
        db_index=True,
441
        help_text='The time when this observing Window starts'
442
    )
443
    end = models.DateTimeField(
2✔
444
        db_index=True,
445
        help_text='The time when this observing Window ends'
446
    )
447

448
    class Meta:
2✔
449
        ordering = ('id',)
2✔
450

451
    def as_dict(self):
2✔
452
        return import_string(settings.AS_DICT['requestgroups']['Window'])(self)
2✔
453

454
    def __str__(self):
2✔
455
        return 'Window {}: {} to {}'.format(self.id, self.start, self.end)
×
456

457

458
class Configuration(models.Model):
2✔
459
    TYPES = (
2✔
460
        ('EXPOSE', 'EXPOSE'),
461
        ('REPEAT_EXPOSE', 'REPEAT_EXPOSE'),
462
        ('SKY_FLAT', 'SKY_FLAT'),
463
        ('SKY', 'SKY'),
464
        ('STANDARD', 'STANDARD'),
465
        ('ARC', 'ARC'),
466
        ('LAMP_FLAT', 'LAMP_FLAT'),
467
        ('SPECTRUM', 'SPECTRUM'),
468
        ('REPEAT_SPECTRUM', 'REPEAT_SPECTRUM'),
469
        ('AUTO_FOCUS', 'AUTO_FOCUS'),
470
        ('TRIPLE', 'TRIPLE'),
471
        ('NRES_TEST', 'NRES_TEST'),
472
        ('NRES_SPECTRUM', 'NRES_SPECTRUM'),
473
        ('REPEAT_NRES_SPECTRUM', 'REPEAT_NRES_SPECTRUM'),
474
        ('NRES_EXPOSE', 'NRES_EXPOSE'),
475
        ('NRES_DARK', 'NRES_DARK'),
476
        ('NRES_BIAS', 'NRES_BIAS'),
477
        ('ENGINEERING', 'ENGINEERING'),
478
        ('SCRIPT', 'SCRIPT'),
479
        ('BIAS', 'BIAS'),
480
        ('DARK', 'DARK')
481
    )
482

483
    SERIALIZER_EXCLUDE = ('request',)
2✔
484

485
    request = models.ForeignKey(
2✔
486
        Request, related_name='configurations', on_delete=models.CASCADE,
487
        help_text='The Request to which this Configuration belongs'
488
    )
489
    instrument_type = models.CharField(
2✔
490
        max_length=255,
491
        help_text='The instrument type used for the observations under this Configuration'
492
    )
493
    # The type of configuration being requested.
494
    # Valid types are in TYPES
495
    # TODO: Get the types from configdb
496
    type = models.CharField(
2✔
497
        max_length=50, choices=TYPES,
498
        help_text='The type of exposures for the observations under this Configuration'
499
    )
500

501
    repeat_duration = models.FloatField(
2✔
502
        verbose_name='configuration duration',
503
        blank=True,
504
        null=True,
505
        validators=[MinValueValidator(0.0)],
506
        help_text='The requested duration for this configuration to be repeated within. '
507
                  'Only applicable to REPEAT_* type configurations. Setting parameter fill_window '
508
                  'to True will cause this value to automatically be filled in to the max '
509
                  'possible given its visibility within the observing window.'
510
    )
511

512
    extra_params = models.JSONField(
2✔
513
        default=dict,
514
        blank=True,
515
        verbose_name='extra parameters',
516
        help_text='Extra Configuration parameters'
517
    )
518
    # Used for ordering the configurations within an Observation
519
    priority = models.IntegerField(
2✔
520
        default=500,
521
        help_text='The order that the Configurations within a Request will be observed. Configurations with priorities '
522
                  'that are lower numbers are executed first.'
523
    )
524

525
    class Meta:
2✔
526
        ordering = ('id',)
2✔
527

528
    def __str__(self):
2✔
UNCOV
529
        return 'Configuration {0}: {1} type'.format(self.id, self.type)
×
530

531
    def as_dict(self):
2✔
532
        return import_string(settings.AS_DICT['requestgroups']['Configuration'])(self)
2✔
533

534
    @cached_property
2✔
535
    def duration(self):
2✔
536
        request_overheads = configdb.get_request_overheads(self.instrument_type)
2✔
537
        return get_configuration_duration(self.as_dict(), request_overheads)['duration']
2✔
538

539

540
class Target(models.Model):
2✔
541
    ORBITAL_ELEMENT_SCHEMES = (
2✔
542
        ('ASA_MAJOR_PLANET', 'ASA_MAJOR_PLANET'),
543
        ('ASA_MINOR_PLANET', 'ASA_MINOR_PLANET'),
544
        ('ASA_COMET', 'ASA_COMET'),
545
        ('JPL_MAJOR_PLANET', 'JPL_MAJOR_PLANET'),
546
        ('JPL_MINOR_PLANET', 'JPL_MINOR_PLANET'),
547
        ('MPC_MINOR_PLANET', 'MPC_MINOR_PLANET'),
548
        ('MPC_COMET', 'MPC_COMET'),
549
    )
550

551
    POINTING_TYPES = (
2✔
552
        ('ICRS', 'ICRS'),
553
        ('ORBITAL_ELEMENTS', 'ORBITAL_ELEMENTS'),
554
        ('HOUR_ANGLE', 'HOUR_ANGLE'),
555
        ('SATELLITE', 'SATELLITE'),
556
    )
557

558
    SERIALIZER_EXCLUDE = ('configuration', 'id')
2✔
559

560
    name = models.CharField(
2✔
561
        max_length=50,
562
        help_text='The name of this Target'
563
    )
564
    configuration = models.OneToOneField(
2✔
565
        Configuration, on_delete=models.CASCADE,
566
        help_text='The configuration to which this Target belongs'
567
    )
568
    type = models.CharField(
2✔
569
        max_length=255, choices=POINTING_TYPES,
570
        help_text='The type of this Target'
571
    )
572

573
    # Coordinate modes
574
    hour_angle = models.FloatField(
2✔
575
        null=True, blank=True,
576
        help_text='Hour angle of this Target'
577
    )
578
    ra = models.FloatField(
2✔
579
        verbose_name='right ascension', null=True, blank=True,
580
        validators=[MinValueValidator(0.0), MaxValueValidator(360.0)],
581
        help_text='Right ascension in decimal degrees'
582
    )
583
    dec = models.FloatField(
2✔
584
        verbose_name='declination', null=True, blank=True,
585
        validators=[MinValueValidator(-90.0), MaxValueValidator(90.0)],
586
        help_text='Declination in decimal degrees'
587
    )
588
    altitude = models.FloatField(
2✔
589
        null=True, blank=True, validators=[MinValueValidator(0.0), MaxValueValidator(90.0)],
590
        help_text='Altitude of this Target'
591
    )
592
    azimuth = models.FloatField(
2✔
593
        null=True, blank=True, validators=[MinValueValidator(0.0), MaxValueValidator(360.0)],
594
        help_text='Azimuth of this Target'
595
    )
596

597
    # Pointing details
598
    proper_motion_ra = models.FloatField(
2✔
599
        verbose_name='right ascension proper motion', null=True, blank=True, validators=[MaxValueValidator(20000)],
600
        help_text='Right ascension proper motion of the Target +/-33 mas/year. Defaults to 0.'
601
    )
602
    proper_motion_dec = models.FloatField(
2✔
603
        verbose_name='declination proper motion', null=True, blank=True, validators=[MaxValueValidator(20000)],
604
        help_text='Declination proper motion of the Target +/-33 mas/year. Defaults to 0.'
605
    )
606
    epoch = models.FloatField(
2✔
607
        max_length=20, null=True, blank=True, validators=[MaxValueValidator(2100)],
608
        help_text='Epoch in Modified Julian Days (MJD). Defaults to 2000.'
609
    )
610
    parallax = models.FloatField(
2✔
611
        null=True, blank=True, validators=[MaxValueValidator(2000)],
612
        help_text='Parallax of the Target ±0.45 mas, max 2000. Defaults to 0.'
613
    )
614

615
    # Nonsidereal rate
616
    diff_altitude_rate = models.FloatField(
2✔
617
        verbose_name='differential altitude rate', null=True, blank=True,
618
        help_text='Differential altitude rate (arcsec/s)'
619
    )
620
    diff_azimuth_rate = models.FloatField(
2✔
621
        verbose_name='differential azimuth rate', null=True, blank=True,
622
        help_text='Differential azimuth rate (arcsec/s)'
623
    )
624
    diff_epoch = models.FloatField(
2✔
625
        verbose_name='differential epoch', null=True, blank=True,
626
        help_text='Reference time for non-sidereal motion (MJD)'
627
    )
628

629
    # Satellite Fields
630
    diff_altitude_acceleration = models.FloatField(
2✔
631
        verbose_name='differential altitude acceleration', null=True, blank=True,
632
        help_text='Differential altitude acceleration (arcsec/s^2)'
633
    )
634
    diff_azimuth_acceleration = models.FloatField(
2✔
635
        verbose_name='differential azimuth acceleration', null=True, blank=True,
636
        help_text='Differential azimuth acceleration (arcsec/s^2)'
637
    )
638

639
    # Orbital elements
640
    scheme = models.CharField(
2✔
641
        verbose_name='orbital element scheme', max_length=50, choices=ORBITAL_ELEMENT_SCHEMES, default='', blank=True,
642
        help_text='The Target scheme to use'
643
    )
644
    epochofel = models.FloatField(
2✔
645
        verbose_name='epoch of elements', null=True, blank=True,
646
        validators=[MinValueValidator(10000), MaxValueValidator(100000)],
647
        help_text='The epoch of the orbital elements (MJD)'
648
    )
649
    orbinc = models.FloatField(
2✔
650
        verbose_name='orbital inclination', null=True, blank=True,
651
        validators=[MinValueValidator(0.0), MaxValueValidator(180.0)],
652
        help_text='Orbital inclination (angle in degrees)'
653
    )
654
    longascnode = models.FloatField(
2✔
655
        verbose_name='longitude of ascending node', null=True, blank=True,
656
        validators=[MinValueValidator(0.0), MaxValueValidator(360.0)],
657
        help_text='Longitude of ascending node (angle in degrees)'
658
    )
659
    longofperih = models.FloatField(
2✔
660
        verbose_name='longitude of perihelion', null=True, blank=True,
661
        validators=[MinValueValidator(0.0), MaxValueValidator(360.0)],
662
        help_text='Longitude of perihelion (angle in degrees)'
663
    )
664
    argofperih = models.FloatField(
2✔
665
        verbose_name='argument of perihelion', null=True, blank=True,
666
        validators=[MinValueValidator(0.0), MaxValueValidator(360.0)],
667
        help_text='Argument of perihelion (angle in degrees)'
668
    )
669
    meandist = models.FloatField(
2✔
670
        verbose_name='mean distance', null=True, blank=True,
671
        help_text='Mean distance (AU)'
672
    )
673
    perihdist = models.FloatField(
2✔
674
        verbose_name='perihelion distance', null=True, blank=True,
675
        help_text='Perihelion distance (AU)'
676
    )
677
    eccentricity = models.FloatField(
2✔
678
        null=True, blank=True, validators=[MinValueValidator(0.0)],
679
        help_text='Eccentricity of the orbit'
680
    )
681
    meanlong = models.FloatField(
2✔
682
        verbose_name='mean longitude', null=True, blank=True,
683
        help_text='Mean longitude (angle in degrees)'
684
    )
685
    meananom = models.FloatField(
2✔
686
        verbose_name='mean anomaly', null=True, blank=True,
687
        validators=[MinValueValidator(0.0), MaxValueValidator(360.0)],
688
        help_text='Mean anomaly (angle in degrees)'
689
    )
690
    dailymot = models.FloatField(
2✔
691
        verbose_name='daily motion', null=True, blank=True,
692
        help_text='Daily motion (angle in degrees)'
693
    )
694
    epochofperih = models.FloatField(
2✔
695
        verbose_name='epoch of perihelion', null=True, blank=True,
696
        validators=[MinValueValidator(361), MaxValueValidator(240000)],
697
        help_text='Epoch of perihelion (MJD)'
698
    )
699

700
    extra_params = models.JSONField(
2✔
701
        default=dict,
702
        blank=True,
703
        verbose_name='extra parameters',
704
        help_text='Extra Target parameters'
705
    )
706

707
    class Meta:
2✔
708
        ordering = ('id',)
2✔
709

710
    def __str__(self):
2✔
UNCOV
711
        return 'Target {}: {} type'.format(self.id, self.type)
×
712

713
    def as_dict(self):
2✔
714
        return import_string(settings.AS_DICT['requestgroups']['Target'])(self)
2✔
715

716
    @property
2✔
717
    def rise_set_target(self):
2✔
UNCOV
718
        return get_rise_set_target(self.as_dict())
×
719

720

721
class InstrumentConfig(models.Model):
2✔
722
    SERIALIZER_EXCLUDE = ('id', 'configuration')
2✔
723

724
    configuration = models.ForeignKey(
2✔
725
        Configuration, related_name='instrument_configs', on_delete=models.CASCADE,
726
        help_text='The Configuration to which this InstrumentConfig belongs'
727
    )
728
    optical_elements = models.JSONField(
2✔
729
        default=dict,
730
        blank=True,
731
        help_text='Specification of optical elements used for this InstrumentConfig'
732
    )
733
    mode = models.CharField(
2✔
734
        max_length=50, default='', blank=True,
735
        help_text='The mode of this InstrumentConfig'
736
    )
737
    exposure_time = models.FloatField(
2✔
738
        validators=[MinValueValidator(0.0)],
739
        help_text='Exposure time in seconds. A tool to aid in deciding on an exposure time is located '
740
                  '<a href="https://lco.global/files/etc/exposure_time_calculator.html">here</a>.'
741
    )
742
    exposure_count = models.PositiveIntegerField(
2✔
743
        validators=[MinValueValidator(1)],
744
        help_text='The number of exposures to take. This field must be set to a value greater than 0.'
745
    )
746
    rotator_mode = models.CharField(
2✔
747
        verbose_name='rotation mode', max_length=50, default='', blank=True,
748
        help_text='(Spectrograph only) How the slit is positioned on the sky. If set to VFLOAT, atmospheric '
749
                  'dispersion is along the slit.'
750
    )
751
    extra_params = models.JSONField(
2✔
752
        default=dict,
753
        blank=True,
754
        verbose_name='extra parameters',
755
        help_text='Extra InstrumentConfig parameters'
756
    )
757

758
    class Meta:
2✔
759
        ordering = ('id',)
2✔
760

761
    def as_dict(self):
2✔
762
        return import_string(settings.AS_DICT['requestgroups']['InstrumentConfig'])(self)
2✔
763

764

765
class RegionOfInterest(models.Model):
2✔
766
    SERIALIZER_EXCLUDE = ('id', 'instrument_config')
2✔
767

768
    instrument_config = models.ForeignKey(
2✔
769
        InstrumentConfig, related_name='rois', on_delete=models.CASCADE,
770
        help_text='The InstrumentConfig to which this RegionOfInterest belongs'
771
    )
772
    x1 = models.PositiveIntegerField(
2✔
773
        null=True, blank=True,
774
        help_text='Sub-frame x start pixel'
775
    )
776
    x2 = models.PositiveIntegerField(
2✔
777
        null=True, blank=True,
778
        help_text='Sub-frame x end pixel'
779
    )
780
    y1 = models.PositiveIntegerField(
2✔
781
        null=True, blank=True,
782
        help_text='Sub-frame y start pixel'
783
    )
784
    y2 = models.PositiveIntegerField(
2✔
785
        null=True, blank=True,
786
        help_text='Sub-frame y end pixel'
787
    )
788

789
    class Meta:
2✔
790
        ordering = ('id',)
2✔
791

792
    def as_dict(self):
2✔
793
        return import_string(settings.AS_DICT['requestgroups']['RegionOfInterest'])(self)
2✔
794

795

796
class GuidingConfig(models.Model):
2✔
797
    OFF = 'OFF'
2✔
798

799
    SERIALIZER_EXCLUDE = ('id', 'configuration')
2✔
800

801
    configuration = models.OneToOneField(
2✔
802
        Configuration, related_name='guiding_config', on_delete=models.CASCADE,
803
        help_text='The Configuration to which this GuidingConfig belongs'
804
    )
805
    optional = models.BooleanField(
2✔
806
        default=True,
807
        help_text='Whether the guiding is optional or not'
808
    )
809
    mode = models.CharField(
2✔
810
        max_length=50, default='', blank=True,
811
        help_text='Guiding mode to use for the observations'
812
    )
813
    optical_elements = models.JSONField(
2✔
814
        default=dict,
815
        blank=True,
816
        help_text='Optical Element specification for this GuidingConfig'
817
    )
818
    exposure_time = models.FloatField(blank=True, null=True,
2✔
819
        validators=[MinValueValidator(0.0), MaxValueValidator(120.0)],
820
        help_text='Guiding exposure time'
821
    )
822
    extra_params = models.JSONField(
2✔
823
        default=dict,
824
        blank=True,
825
        verbose_name='extra parameters',
826
        help_text='Extra GuidingConfig parameters'
827
    )
828

829
    class Meta:
2✔
830
        ordering = ('id',)
2✔
831

832
    def as_dict(self):
2✔
833
        return import_string(settings.AS_DICT['requestgroups']['GuidingConfig'])(self)
2✔
834

835

836
class AcquisitionConfig(models.Model):
2✔
837
    OFF = 'OFF'
2✔
838
    SERIALIZER_EXCLUDE = ('id', 'configuration')
2✔
839

840
    configuration = models.OneToOneField(
2✔
841
        Configuration, related_name='acquisition_config', on_delete=models.CASCADE,
842
        help_text='The Configuration to which this AcquisitionConfig belongs'
843
    )
844
    mode = models.CharField(
2✔
845
        max_length=50, default=OFF,
846
        help_text='AcquisitionConfig mode to use for the observations'
847
    )
848
    exposure_time = models.FloatField(blank=True, null=True,
2✔
849
        validators=[MinValueValidator(0.0), MaxValueValidator(60.0)],
850
        help_text='Acquisition exposure time'
851
    )
852
    extra_params = models.JSONField(
2✔
853
        default=dict,
854
        blank=True,
855
        verbose_name='extra parameters',
856
        help_text='Extra AcquisitionConfig parameters'
857
    )
858

859
    class Meta:
2✔
860
        ordering = ('id',)
2✔
861

862
    def as_dict(self):
2✔
863
        return import_string(settings.AS_DICT['requestgroups']['AcquisitionConfig'])(self)
2✔
864

865

866

867
class Constraints(models.Model):
2✔
868
    SERIALIZER_EXCLUDE = ('configuration', 'id')
2✔
869

870
    configuration = models.OneToOneField(
2✔
871
        Configuration, on_delete=models.CASCADE,
872
        help_text='The Configuration to which these Constraints belong'
873
    )
874
    max_airmass = models.FloatField(
2✔
875
        verbose_name='maximum airmass', default=1.6, validators=[MinValueValidator(1.0), MaxValueValidator(25.0)],
876
        help_text='Maximum acceptable airmass. At zenith, the airmass equals 1 and increases with zenith distance. '
877
                  'Assumes a plane-parallel atmosphere. You can read about the considerations of setting the airmass '
878
                  'limit <a href="https://lco.global/documentation/airmass-limit/">here</a>.'
879
    )
880
    min_lunar_distance = models.FloatField(
2✔
881
        verbose_name='minimum lunar distance', default=30.0,
882
        validators=[MinValueValidator(0.0), MaxValueValidator(180.0)],
883
        help_text='Minimum acceptable angular separation between the target and the moon in decimal degrees'
884
    )
885
    max_lunar_phase = models.FloatField(
2✔
886
        verbose_name='maximum lunar phase', default=1.0,
887
        validators=[MinValueValidator(0.0), MaxValueValidator(1.0)],
888
        help_text='Maximum acceptable lunar phase fraction from 0.0 (new moon) to 1.0 (full moon)'
889
    )
890
    max_seeing = models.FloatField(
2✔
891
        verbose_name='maximum seeing', null=True, blank=True,
892
        validators=[MinValueValidator(0.0)],
893
        help_text='Maximum acceptable seeing'
894
    )
895
    min_transparency = models.FloatField(
2✔
896
        verbose_name='minimum transparency', null=True, blank=True,
897
        help_text='Minimum acceptable transparency'
898
    )
899
    extra_params = models.JSONField(
2✔
900
        default=dict,
901
        blank=True,
902
        verbose_name='extra parameters',
903
        help_text='Extra Constraints parameters'
904
    )
905

906
    class Meta:
2✔
907
        ordering = ('id',)
2✔
908
        verbose_name_plural = 'Constraints'
2✔
909

910
    def as_dict(self):
2✔
911
        return import_string(settings.AS_DICT['requestgroups']['Constraints'])(self)
2✔
912

913
    def __str__(self):
2✔
UNCOV
914
        return 'Constraints {}: {} max airmass, {} min_lunar_distance, {} max_lunar_phase'.format(
×
915
            self.id, self.max_airmass, self.min_lunar_distance, self.max_lunar_phase
916
        )
917

918

919
class DraftRequestGroup(models.Model):
2✔
920
    author = models.ForeignKey(User, on_delete=models.CASCADE)
2✔
921
    proposal = models.ForeignKey(Proposal, on_delete=models.CASCADE)
2✔
922
    title = models.CharField(max_length=50)
2✔
923
    content = models.TextField()
2✔
924
    created = models.DateTimeField(auto_now_add=True)
2✔
925
    modified = models.DateTimeField(auto_now=True)
2✔
926

927
    class Meta:
2✔
928
        ordering = ['-modified']
2✔
929
        unique_together = ('author', 'proposal', 'title')
2✔
930

931
    def __str__(self):
2✔
UNCOV
932
        return 'Draft request by: {} for proposal: {}'.format(self.author, self.proposal)
×
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

© 2026 Coveralls, Inc