Skip to content

Commit

Permalink
Merge pull request #48 from observatorycontrolsystem/feature/availabi…
Browse files Browse the repository at this point in the history
…lity_history

Added availability_history endpoint for getting historical time inter…
  • Loading branch information
jnation3406 authored Mar 25, 2024
2 parents 6cedcb5 + 7afafde commit 71e9b9d
Show file tree
Hide file tree
Showing 9 changed files with 1,151 additions and 686 deletions.
10 changes: 4 additions & 6 deletions .github/workflows/build.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -50,12 +50,10 @@ jobs:
DB_PASS: postgres
DB_HOST: localhost
DB_PORT: 5432
- name: Coveralls report
run: |
pip install --upgrade coveralls
coveralls --service=github
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Generate and send coveralls report
uses: coverallsapp/github-action@v2
with:
parallel: true

build_and_publish_image:
# Only run this job if the run_tests job has succeeded, and if
Expand Down
6 changes: 6 additions & 0 deletions CHANGELOG
Original file line number Diff line number Diff line change
@@ -1,3 +1,9 @@
3.0.5
2024-03-01
Add /api/availability_history endpoint that leverages the django-reversion-redux version
data to return time intervals that instruments were SCHEDULABLE, or time intervals that
telescopes had at least one SCHEDULABLE instrument.

3.0.4
2023-02-28
Add validation_schema to ConfigurationTypeProperties model to allow for more flexible validation of instrument configs per instrument type/configuration type
Expand Down
87 changes: 87 additions & 0 deletions configdb/hardware/availability.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@

from time_intervals.intervals import Intervals
from reversion.models import Version
from django.utils import timezone

from configdb.hardware.models import Instrument


# This is here for legacy compatibility with old configdb Versions that stored the state as a number
STATE_MAPPING = {
'0': 'DISABLED',
'10': 'MANUAL',
'20': 'COMMISSIONING',
'25': 'STANDBY',
'30': 'SCHEDULABLE'
}


def state_conversion(version):
""" For legacy compatibility with older numeric styled states """
return STATE_MAPPING.get(version.field_dict['state'], version.field_dict['state'])


def build_availability_history(instance, initial, state_function, comparator):
""" Utility method builds a set of availability windows using the reversion Version set for the object.
This generic method takes in the initial value for the field you are using as the "state", as well
as a function that retrieves the "state" from the version field_dict, and a value to compare said
"state" to to see if you are "in" that "state".
"""
availability = []
versions = Version.objects.get_for_object(instance)
current_state = initial
current_time = timezone.now()
if current_state == comparator:
is_in_state = True
state_start_time = current_time
state_end_time = current_time
else:
is_in_state = False
state_start_time = None
state_end_time = None
# Versions are in reverse time order, latest to earliest
for version in versions:
state = state_function(version)
if state == current_state:
if is_in_state:
state_start_time = version.field_dict['modified']
else:
if is_in_state:
availability.append((state_start_time, state_end_time))
is_in_state = False
elif state == comparator:
is_in_state = True
state_start_time = version.field_dict['modified']
state_end_time = current_time
current_time = version.field_dict['modified']
current_state = state
# If we've gone through all versions and the last was in_state, then add the last window
if is_in_state:
availability.append((state_start_time, state_end_time))
return availability


def build_instrument_availability_history(instrument):
""" Utility method to build a set of availability windows for an instrument given its version history
Outputs a list of tuples of (start, end) times for intervals when the instrument is schedulable
"""
return build_availability_history(instrument, instrument.state, state_conversion, Instrument.SCHEDULABLE)


def build_telescope_availability_history(telescope):
""" Utility method to build a set of availability windows for an telescope given its version history.
A telescope is considered available if it had any instruments in the SCHEDULABLE state during a time interval.
Outputs a list of tuples of (start, end) times for intervals when the telescope is available.
"""
instrument_intervals = []
for instrument in telescope.instrument_set.all():
instrument_intervals.append(Intervals(build_instrument_availability_history(instrument)))
combined_instrument_availability = Intervals().union(instrument_intervals)

# We can also check the telescope active state history and enforce that as well
telescope_availability = build_availability_history(
telescope, telescope.active, lambda version: version.field_dict['active'], True
)
telescope_intervals = Intervals(telescope_availability)
# Reverse the intervals so the latest is first
return reversed(combined_instrument_availability.intersect([telescope_intervals]).toTupleList())
144 changes: 135 additions & 9 deletions configdb/hardware/tests.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
import json
import reversion
import time_machine
from datetime import datetime
from http import HTTPStatus
from django.test import TestCase
from django.test import Client
from django.urls import reverse
from django.contrib.auth.models import User
from mixer.backend.django import mixer

Expand All @@ -10,28 +14,36 @@
from .serializers import GenericModeSerializer, InstrumentTypeSerializer


class SimpleHardwareTest(TestCase):
class BaseHardwareTest(TestCase):
def setUp(self):
User.objects.create_user('tst_user', password='tst_pass')
self.client = Client()

self.site = mixer.blend(Site)
self.enclosure = mixer.blend(Enclosure, site=self.site)
self.telescope = mixer.blend(Telescope, enclosure=self.enclosure)

self.site = mixer.blend(Site, code='tst')
self.enclosure = mixer.blend(Enclosure, site=self.site, code='doma')
self.telescope = mixer.blend(Telescope, enclosure=self.enclosure, code='1m0a', active=True)
self.camera_type = mixer.blend(CameraType)
self.instrument_type = mixer.blend(InstrumentType)
self.camera_type.save()
self.camera = mixer.blend(Camera, camera_type=self.camera_type)
self.instrument = mixer.blend(Instrument, autoguider_camera=self.camera, telescope=self.telescope,
instrument_type=self.instrument_type, science_cameras=[self.camera])
instrument_type=self.instrument_type, science_cameras=[self.camera],
state=Instrument.SCHEDULABLE, code='myInst01')


class SimpleHardwareTest(BaseHardwareTest):
def setUp(self):
super().setUp()
# Set instrument to initially be disabled
self.instrument.state = Instrument.DISABLED
self.instrument.save()

def test_homepage(self):
response = self.client.get('/')
self.assertContains(response, 'ConfigDB3', status_code=200)

def test_write_site(self):
site = {'name': 'Test Site', 'code': 'tst', 'active': True, 'timezone': -7, 'lat': 33.33, 'long': 22.22,
site = {'name': 'Test Site', 'code': 'tss', 'active': True, 'timezone': -7, 'lat': 33.33, 'long': 22.22,
'elevation': 1236, 'tz': 'US/Mountain', 'restart': '19:00:00'}
self.client.login(username='tst_user', password='tst_pass')
self.client.post('/sites/', site)
Expand Down Expand Up @@ -81,7 +93,7 @@ def test_patch_instrument_invalid_state_fails(self):
self.assertEqual(response.status_code, HTTPStatus.BAD_REQUEST)

def test_or_instrument_states(self):
new_instrument = mixer.blend(Instrument, autoguider_camera=self.camera, telescope=self.telescope,
mixer.blend(Instrument, autoguider_camera=self.camera, telescope=self.telescope,
instrument_type=self.instrument_type, science_cameras=[self.camera],
state=Instrument.SCHEDULABLE)

Expand All @@ -91,7 +103,7 @@ def test_or_instrument_states(self):

response = self.client.get('/instruments/', data={'state': 'DISABLED'}, content_type='application/x-www-form-urlencoded')
self.assertEqual(len(response.json()['results']), 1)
self.assertEqual(str(new_instrument), response.json()['results'][0]['__str__'])
self.assertEqual(str(self.instrument), response.json()['results'][0]['__str__'])

response = self.client.get('/instruments/', data={'state': 'SCHEDULABLE'}, content_type='application/x-www-form-urlencoded')
self.assertEqual(len(response.json()['results']), 1)
Expand Down Expand Up @@ -126,3 +138,117 @@ def test_optical_elements_str(self):
self.assertEqual(str(oe1), 'oe1')
oeg = mixer.blend(OpticalElementGroup, type='oeg_type', name='oeg_name', optical_elements=[oe1, oe2])
self.assertEqual(str(oeg), 'oeg_name - oeg_type: oe1,oe2')


class TestAvailabilityHistory(BaseHardwareTest):
def setUp(self):
super().setUp()
# Now setup the initial reversion Versions for the availability history test
with time_machine.travel("2023-01-01 00:00:00"):
with reversion.create_revision():
self.instrument.save()
with reversion.create_revision():
self.telescope.save()

def _update_instrument_revision(self, instrument, state, modified):
with time_machine.travel(modified):
with reversion.create_revision():
instrument.state = state
instrument.save()

def _update_telescope_revision(self, telescope, active, modified):
with time_machine.travel(modified):
with reversion.create_revision():
telescope.active = active
telescope.save()

def test_requires_instrument_or_telescope_defined(self):
with time_machine.travel("2024-01-01 00:00:00"):
response = self.client.get(reverse('availability') + f'?telescope_id={self.telescope.code}&site_id={self.site.code}')
self.assertContains(response, 'Must supply either instrument_id or site_id, enclosure_id, and telescope_id in params', status_code=400)

def test_requires_instrument_to_exist(self):
with time_machine.travel("2024-01-01 00:00:00"):
response = self.client.get(reverse('availability') + '?instrument_id=FakeInst')
self.assertContains(response, 'No instrument found with code FakeInst', status_code=404)

def test_requires_telescope_to_exist(self):
with time_machine.travel("2024-01-01 00:00:00"):
response = self.client.get(reverse('availability') + f'?telescope_id=FakeCode&site_id={self.site.code}&enclosure_id={self.enclosure.code}')
self.assertContains(response, f'No telescope found with code {self.site.code}.{self.enclosure.code}.FakeCode', status_code=404)

def test_requires_date_params_be_parseable(self):
with time_machine.travel("2024-01-01 00:00:00"):
response = self.client.get(reverse('availability') + f'?instrument_id={self.instrument.code}&start=notadate')
self.assertContains(response, 'The format used for the start/end parameters is not parseable', status_code=400)

def test_instrument_availability_history(self):
self._update_instrument_revision(self.instrument, Instrument.MANUAL, "2023-02-01 00:00:00")
self._update_instrument_revision(self.instrument, Instrument.SCHEDULABLE, "2023-03-01 00:00:00")
with time_machine.travel("2024-01-01 00:00:00"):
response = self.client.get(reverse('availability') + f'?instrument_id={self.instrument.code}')
expected_intervals = {'availability_intervals': [
{'start': datetime(2023, 3, 1).isoformat(), 'end': datetime(2024, 1, 1).isoformat()},
{'start': datetime(2023, 1, 1).isoformat(), 'end': datetime(2023, 2, 1).isoformat()}]}
self.assertEqual(response.json(), expected_intervals)

def test_instrument_availability_history_caps_start(self):
self._update_instrument_revision(self.instrument, Instrument.MANUAL, "2023-02-01 00:00:00")
self._update_instrument_revision(self.instrument, Instrument.SCHEDULABLE, "2023-03-01 00:00:00")
with time_machine.travel("2024-01-01 00:00:00"):
response = self.client.get(reverse('availability') + f'?instrument_id={self.instrument.code}&start=2023-04-01')
expected_intervals = {'availability_intervals': [
{'start': datetime(2023, 3, 1).isoformat(), 'end': datetime(2024, 1, 1).isoformat()}]}
self.assertEqual(response.json(), expected_intervals)

def test_instrument_availability_history_caps_start_end(self):
self._update_instrument_revision(self.instrument, Instrument.MANUAL, "2023-02-01 00:00:00")
self._update_instrument_revision(self.instrument, Instrument.SCHEDULABLE, "2023-03-01 00:00:00")
self._update_instrument_revision(self.instrument, Instrument.MANUAL, "2023-04-01 00:00:00")
self._update_instrument_revision(self.instrument, Instrument.SCHEDULABLE, "2023-05-01 00:00:00")
with time_machine.travel("2024-01-01 00:00:00"):
response = self.client.get(reverse('availability') + f'?instrument_id={self.instrument.code}&start=2023-03-10&end=2023-04-10')
expected_intervals = {'availability_intervals': [
{'start': datetime(2023, 3, 1).isoformat(), 'end': datetime(2023, 4, 1).isoformat()}]}
self.assertEqual(response.json(), expected_intervals)

def test_telescope_availability_history(self):
self._update_telescope_revision(self.telescope, False, "2023-02-10 00:00:00")
self._update_telescope_revision(self.telescope, True, "2023-03-10 00:00:00")

with time_machine.travel("2024-01-01 00:00:00"):
response = self.client.get(reverse('availability') + f'?telescope_id={self.telescope.code}&site_id={self.site.code}&enclosure_id={self.enclosure.code}')
expected_intervals = {'availability_intervals': [
{'start': datetime(2023, 3, 10).isoformat(), 'end': datetime(2024, 1, 1).isoformat()},
{'start': datetime(2023, 1, 1).isoformat(), 'end': datetime(2023, 2, 10).isoformat()}]}
self.assertEqual(response.json(), expected_intervals)

def test_telescope_and_instrument_availability_history(self):
self._update_instrument_revision(self.instrument, Instrument.MANUAL, "2023-02-01 00:00:00")
self._update_instrument_revision(self.instrument, Instrument.SCHEDULABLE, "2023-03-01 00:00:00")
self._update_telescope_revision(self.telescope, False, "2023-02-10 00:00:00")
self._update_telescope_revision(self.telescope, True, "2023-03-10 00:00:00")

with time_machine.travel("2024-01-01 00:00:00"):
response = self.client.get(reverse('availability') + f'?telescope_id={self.telescope.code}&site_id={self.site.code}&enclosure_id={self.enclosure.code}')
expected_intervals = {'availability_intervals': [
{'start': datetime(2023, 3, 10).isoformat(), 'end': datetime(2024, 1, 1).isoformat()},
{'start': datetime(2023, 1, 1).isoformat(), 'end': datetime(2023, 2, 1).isoformat()}]}
self.assertEqual(response.json(), expected_intervals)

def test_multiple_instrument_availability_history(self):
# Add a second instrument on the telescope that is always available and see that the telescope shows always available
instrument_2 = mixer.blend(Instrument, autoguider_camera=self.camera, telescope=self.telescope,
instrument_type=self.instrument_type, science_cameras=[self.camera],
state=Instrument.SCHEDULABLE, code='myInst02')
with time_machine.travel("2023-01-01 00:00:00"):
with reversion.create_revision():
instrument_2.save()
self._update_instrument_revision(self.instrument, Instrument.MANUAL, "2023-02-01 00:00:00")
self._update_instrument_revision(self.instrument, Instrument.SCHEDULABLE, "2023-03-01 00:00:00")

with time_machine.travel("2024-01-01 00:00:00"):
response = self.client.get(reverse('availability') + f'?telescope_id={self.telescope.code}&site_id={self.site.code}&enclosure_id={self.enclosure.code}')
expected_intervals = {'availability_intervals': [
{'start': datetime(2023, 1, 1).isoformat(), 'end': datetime(2024, 1, 1).isoformat()}]}
self.assertEqual(response.json(), expected_intervals)
61 changes: 59 additions & 2 deletions configdb/hardware/views.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
from datetime import datetime
from dateutil.parser import parse
from django.utils import timezone
from django.views.generic import TemplateView
from django.http import JsonResponse, HttpResponseBadRequest, HttpResponseNotFound
from rest_framework.generics import RetrieveAPIView

from .models import Site, Telescope, Camera, Instrument, OpticalElementGroup, GenericModeGroup

from configdb.hardware.models import Site, Telescope, Camera, Instrument, OpticalElementGroup, GenericModeGroup
from configdb.hardware.availability import build_instrument_availability_history, build_telescope_availability_history

class IndexView(TemplateView):
template_name = 'hardware/index.html'
Expand All @@ -15,3 +20,55 @@ def get_context_data(self, **kwargs):
context['opticalelementgroup_count'] = OpticalElementGroup.objects.count()
context['genericmodegroup_count'] = GenericModeGroup.objects.count()
return context


class AvailabilityHistoryView(RetrieveAPIView):
""" Use django-reversion models to build a set of timestamps for when an instrument or telescope has availability
Meaning it has at least one schedulable instrument
"""
def get(self, request):
instrument_id = request.GET.get('instrument_id')
telescope_id = request.GET.get('telescope_id')
site_id = request.GET.get('site_id')
enclosure_id = request.GET.get('enclosure_id')
# Start/end are optional parameters to cap what is returned
start = request.GET.get('start')
end = request.GET.get('end')
try:
if start:
start = parse(start).replace(tzinfo=timezone.utc)
else:
start = datetime(2010, 1, 1, tzinfo=timezone.utc)
if end:
end = parse(end).replace(tzinfo=timezone.utc)
else:
end = timezone.now()
except Exception:
return HttpResponseBadRequest('The format used for the start/end parameters is not parseable')

if instrument_id == None and (telescope_id == None or site_id == None or enclosure_id == None):
return HttpResponseBadRequest('Must supply either instrument_id or site_id, enclosure_id, and telescope_id in params')
availability = []
# If use specifys a specific instrument_id, then use that
if instrument_id:
# Assume we only have a single instrument with that code
instrument = Instrument.objects.filter(code=instrument_id).first()
if not instrument:
return HttpResponseNotFound(f'No instrument found with code {instrument_id}')
availability = build_instrument_availability_history(instrument)

# Otherwise if a telescope_id is provided, check accross all instruments on that telescope
if telescope_id and enclosure_id and site_id:
telescope = Telescope.objects.filter(code=telescope_id, enclosure__code=enclosure_id, enclosure__site__code=site_id).first()
if not telescope:
return HttpResponseNotFound(f'No telescope found with code {site_id}.{enclosure_id}.{telescope_id}')
availability = build_telescope_availability_history(telescope)

availability_data = {'availability_intervals': []}
for interval in availability:
if interval[0] <= end and interval[1] >= start:
availability_data['availability_intervals'].append({
'start': interval[0].replace(microsecond=0, tzinfo=None).isoformat(),
'end': interval[1].replace(microsecond=0, tzinfo=None).isoformat()
})
return JsonResponse(data=availability_data)
Loading

0 comments on commit 71e9b9d

Please sign in to comment.