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

observatorycontrolsystem / observation-portal / 21305899195

24 Jan 2026 12:35AM UTC coverage: 96.593% (+0.02%) from 96.577%
21305899195

Pull #352

github

Jon
Added suspend_until field to Requst and serializer and used it to filter out schedulable requests
Pull Request #352: Added suspend_until field to Requst and serializer and used it to fil…

174 of 176 new or added lines in 5 files covered. (98.86%)

52 existing lines in 3 files now uncovered.

36122 of 37396 relevant lines covered (96.59%)

2.89 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
2✔
2
from django.contrib.auth.models import User
3✔
3
from django.utils.functional import cached_property
3✔
4
from django.core.validators import MinValueValidator, MaxValueValidator
3✔
5
from django.core.cache import cache
3✔
6
from django.utils import timezone
3✔
7
from django.urls import reverse
3✔
8
from django.forms.models import model_to_dict
3✔
9
from django.utils.module_loading import import_string
3✔
10
from django.conf import settings
3✔
11
import logging
3✔
12

13
from observation_portal.common.configdb import configdb
3✔
14
from observation_portal.proposals.models import Proposal, TimeAllocationKey
3✔
15
from observation_portal.requestgroups.target_helpers import TARGET_TYPE_HELPER_MAP
3✔
16
from observation_portal.common.rise_set_utils import get_rise_set_target
3✔
17
from observation_portal.requestgroups.duration_utils import (
3✔
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__)
3✔
27

28

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

38

39
def request_as_dict(instance, for_observation=False):
3✔
40
    ret_dict = model_to_dict(instance, exclude=instance.SERIALIZER_EXCLUDE)
3✔
41
    ret_dict['modified'] = instance.modified
3✔
42
    ret_dict['duration'] = instance.duration
3✔
43
    ret_dict['configurations'] = [c.as_dict() for c in instance.configurations.all()]
3✔
44
    if not for_observation:
3✔
45
        if instance.request_group.observation_type in RequestGroup.NON_SCHEDULED_TYPES:
3✔
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 {}
3✔
53
            ret_dict['windows'] = [w.as_dict() for w in instance.windows.all()]
3✔
54
    return ret_dict
3✔
55

56

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

62

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

66

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

79

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

88

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

94

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

98

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

102

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

108

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

114

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

123
    STATE_CHOICES = (
3✔
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 = (
3✔
132
        ('SINGLE', 'SINGLE'),
133
        ('MANY', 'MANY'),
134
    )
135

136
    OBSERVATION_TYPES = (
3✔
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(
3✔
145
        User, on_delete=models.CASCADE,
146
        help_text='The user that submitted this RequestGroup'
147
    )
148
    proposal = models.ForeignKey(
3✔
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(
3✔
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(
3✔
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(
3✔
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(
3✔
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(
3✔
180
        auto_now_add=True, db_index=True,
181
        help_text='Time when this RequestGroup was created'
182
    )
183
    state = models.CharField(
3✔
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(
3✔
188
        auto_now=True, db_index=True,
189
        help_text='Time when this RequestGroup was last changed'
190
    )
191

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

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

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

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

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

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

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

215
    @property
3✔
216
    def timeallocations(self):
3✔
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
3✔
223
    def total_duration(self):
3✔
224
        cached_duration = cache.get('requestgroup_duration_{}'.format(self.id))
3✔
225
        if not cached_duration:
3✔
226
            duration = get_total_duration_dict(self.as_dict())
3✔
227
            cache.set('requestgroup_duration_{}'.format(self.id), duration, 86400 * 30 * 6)
3✔
228
            return duration
3✔
229
        else:
230
            return cached_duration
×
231

232

233
class Request(models.Model):
3✔
234
    STATE_CHOICES = (
3✔
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 = (
3✔
243
        ('TIME', 'TIME'),
244
        ('AIRMASS', 'AIRMASS'),
245
    )
246

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

249
    request_group = models.ForeignKey(
3✔
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(
3✔
254
        max_length=255, default='', blank=True,
255
        help_text='Text describing this Request'
256
    )
257
    optimization_type = models.CharField(
3✔
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(
3✔
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(
3✔
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(
3✔
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')
3✔
276

277
    # Minimum completable observation threshold
278
    acceptability_threshold = models.FloatField(
3✔
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(
3✔
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(
3✔
291
        default=dict,
292
        blank=True,
293
        verbose_name='extra parameters',
294
        help_text='Extra Request parameters'
295
    )
296

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

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

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

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

309
    @cached_property
3✔
310
    def duration(self):
3✔
311
        cached_duration = cache.get('request_duration_{}'.format(self.id))
3✔
312
        if not cached_duration:
3✔
313
            duration = get_total_request_duration({'configurations': [c.as_dict() for c in self.configurations.all()],
3✔
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)
3✔
317
            return duration
3✔
318
        else:
UNCOV
319
            return cached_duration
×
320

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

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

329
    @property
3✔
330
    def semester(self):
3✔
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)
3✔
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:
3✔
337
            observation = self.observation_set.first()
3✔
338
            if observation:
3✔
339
                semester = get_semester_in(observation.start, observation.start)
3✔
340
        # Fall back to using the semester that contains the first window start time.
341
        if semester is None:
3✔
342
            semester = get_semester_in(self.min_window_time, self.min_window_time)
3✔
343
        return semester
3✔
344

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

352
    @property
3✔
353
    def timeallocations(self):
3✔
354
        return self.request_group.proposal.timeallocation_set.filter(
3✔
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):
3✔
361
        request_dict = self.as_dict()
3✔
362
        start_time = (min([window['start'] for window in request_dict['windows']])
3✔
363
                      if 'windows' in request_dict and request_dict['windows'] else timezone.now())
364
        try:
3✔
365
            configurations = sorted(request_dict['configurations'], key=lambda x: x['priority'])
3✔
UNCOV
366
        except KeyError:
×
UNCOV
367
            configurations = request_dict['configurations']
×
368
        duration = get_total_complete_configurations_duration(
3✔
369
            configurations,
370
            start_time,
371
            configurations_after_priority
372
        )
373
        if self.configuration_repeats > current_repeat:
3✔
374
            # We have configuration repeats left, so add multiples of the total configuration duration
375
            config_duration = get_total_complete_configurations_duration(
3✔
376
                configurations,
377
                start_time
378
            )
379
            repeats_left = self.configuration_repeats - current_repeat
3✔
380
            duration += (repeats_left * config_duration)
3✔
381
        if include_current:
3✔
382
            previous_optical_elements = {}
3✔
383
            for configuration_dict in configurations:
3✔
384
                if configuration_dict['priority'] == configurations_after_priority:
3✔
385
                    request_overheads = configdb.get_request_overheads(configuration_dict['instrument_type'])
3✔
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']
3✔
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)
3✔
390
                    break
3✔
391
                else:
392
                    previous_optical_elements = configuration_dict['instrument_configs'][-1].get('optical_elements', {})
3✔
393

394
        return duration
3✔
395

396

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

400
    request = models.OneToOneField(
3✔
401
        Request, on_delete=models.CASCADE,
402
        help_text='The Request to which this Location applies'
403
    )
404
    telescope_class = models.CharField(
3✔
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(
3✔
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(
3✔
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(
3✔
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:
3✔
423
        ordering = ('id',)
3✔
424

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

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

431

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

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

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

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

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

457

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

482
    SERIALIZER_EXCLUDE = ('request',)
3✔
483

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

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

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

524
    class Meta:
3✔
525
        ordering = ('id',)
3✔
526

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

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

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

538

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

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

557
    SERIALIZER_EXCLUDE = ('configuration', 'id')
3✔
558

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

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

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

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

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

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

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

706
    class Meta:
3✔
707
        ordering = ('id',)
3✔
708

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

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

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

719

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

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

757
    class Meta:
3✔
758
        ordering = ('id',)
3✔
759

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

763

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

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

788
    class Meta:
3✔
789
        ordering = ('id',)
3✔
790

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

794

795
class GuidingConfig(models.Model):
3✔
796
    OFF = 'OFF'
3✔
797

798
    SERIALIZER_EXCLUDE = ('id', 'configuration')
3✔
799

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

828
    class Meta:
3✔
829
        ordering = ('id',)
3✔
830

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

834

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

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

858
    class Meta:
3✔
859
        ordering = ('id',)
3✔
860

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

864

865

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

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

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

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

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

917

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

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

930
    def __str__(self):
3✔
UNCOV
931
        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