Skip to content

Commit ef90b5f

Browse files
authored
Merge pull request #51 from observatorycontrolsystem/feature/integrate_heroic
Added settings to enable sending new structures and updates to instru… Failing test looks like its due to github server issues right now
2 parents e80de50 + 1cf7de9 commit ef90b5f

File tree

9 files changed

+674
-5
lines changed

9 files changed

+674
-5
lines changed

README.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,11 @@ This project is configured using environment variables.
3333
| `OAUTH_TOKEN_URL` | OAuth2 token URL, set this to use OAuth2 authentication | `""` |
3434
| `OAUTH_PROFILE_URL` | Observation portal profile endpoint, used to retrieve details on user accounts | `""` |
3535
| `OAUTH_SERVER_KEY` | Observation portal server secret key to authenticate calls from the server | `""` |
36+
| `HEROIC_API_URL` | HEROIC server api url, required for submitting your observatory updates to the HEROIC service | `""` |
37+
| `HEROIC_API_TOKEN` | HEROIC server api token, required for submitting your observatory updates to the HEROIC service | `""` |
38+
| `HEROIC_OBSERVATORY` | HEROIC server observatory code, required for submitting your observatory updates to the HEROIC service | `""` |
39+
| `HEROIC_EXCLUDE_SITES` | Comma delimited list of site codes to ignore when sending updates to HEROIC | `""` |
40+
| `HEROIC_EXCLUDE_TELESCOPES` | Comma delimited list of site.enclosure.telescope codes to ignore when sending updates to HEROIC | `""` |
3641

3742
## Local Development
3843

@@ -145,3 +150,7 @@ Return a specific camera's configuration
145150
Return all instruments that are in the SCHEDULABLE state
146151

147152
GET /instruments/?state=SCHEDULABLE
153+
154+
## Sending data to HEROIC
155+
156+
HEROIC is a service provided by Scimma through the NSF that accepts and stores observatory information, including instrument configuration and telescope status. By default, no data will be sent to the HEROIC service. If you want to send your observatory updates to HEROIC, you must set all the `HEROIC_*` environment variables. You must login to the HEROIC server, retrieve your API token, and request that an Observatory is created for you with your account as the admin for that observatory. Afterwards, by setting the appropriate environment variables your configuration database should automatically send updates to HEROIC when updates are made through the API or admin interface.

configdb/hardware/admin.py

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
from reversion.admin import VersionAdmin
1212
from reversion.errors import RegistrationError
1313

14+
from configdb.hardware.heroic import update_heroic_instrument_capabilities
1415
from configdb.hardware.models import (
1516
Site, Enclosure, GenericMode, ModeType, GenericModeGroup, Telescope, Instrument, Camera, CameraType,
1617
OpticalElementGroup, OpticalElement, InstrumentType, InstrumentCategory, ConfigurationType, ConfigurationTypeProperties
@@ -165,12 +166,27 @@ class InstrumentAdmin(HardwareAdmin):
165166
def science_camera_codes(self, obj):
166167
return ','.join([science_camera.code for science_camera in obj.science_cameras.all()])
167168

169+
def save_related(self, request, form, formsets, change):
170+
# This is the best way to trigger on saving m2m admin relationships on the Instrument, like Science Cameras
171+
finished = super().save_related(request, form, formsets, change)
172+
update_heroic_instrument_capabilities(form.instance)
173+
return finished
174+
168175
@admin.register(Camera)
169176
class CameraAdmin(HardwareAdmin):
170177
form = CameraAdminForm
171178
list_display = ('code', 'camera_type')
172179
search_fields = ('code',)
173180

181+
def save_related(self, request, form, formsets, change):
182+
# This is the best way to trigger on saving m2m admin relationships on the Camera, like Optical Element Groups
183+
old_oegs = set(form.instance.optical_element_groups.all())
184+
finished = super().save_related(request, form, formsets, change)
185+
if old_oegs != set(form.instance.optical_element_groups.all()):
186+
for instrument in form.instance.instrument_set.all():
187+
update_heroic_instrument_capabilities(instrument)
188+
return finished
189+
174190

175191
@admin.register(InstrumentType)
176192
class InstrumentTypeAdmin(HardwareAdmin):
@@ -193,13 +209,47 @@ class GenericModeGroupAdmin(HardwareAdmin):
193209
search_fields = ('instrument_type', 'type')
194210
list_filter = ('type', 'instrument_type')
195211

212+
def save_model(self, request, obj, form, change):
213+
old_instrument_type = None
214+
if obj.pk:
215+
old_obj = self.model.objects.get(pk=obj.pk)
216+
if old_obj.instrument_type != obj.instrument_type:
217+
old_instrument_type = old_obj.instrument_type
218+
# Now update the model so the new model details are saved
219+
finished = super().save_model(request, obj, form, change)
220+
221+
if old_instrument_type:
222+
# The instrument_type has changed so update heroic for the old instrument type
223+
for instrument in old_instrument_type.instrument_set.all():
224+
update_heroic_instrument_capabilities(instrument)
225+
return finished
226+
227+
def save_related(self, request, form, formsets, change):
228+
# This is the best way to trigger on saving m2m admin relationships on the GenericModeGroup,
229+
# like when its members change
230+
finished = super().save_related(request, form, formsets, change)
231+
if form.instance.instrument_type:
232+
for instrument in form.instance.instrument_type.instrument_set.all():
233+
update_heroic_instrument_capabilities(instrument)
234+
return finished
235+
196236

197237
@admin.register(GenericMode)
198238
class GenericModeAdmin(HardwareAdmin):
199239
form = GenericModeAdminForm
200240
list_display = ('name', 'code', 'overhead', 'schedulable')
201241
search_fields = ('name', 'code')
202242

243+
def save_related(self, request, form, formsets, change):
244+
# This is the best way to trigger on saving m2m admin relationships on the GenericMode,
245+
# like when its membersships change
246+
finished = super().save_related(request, form, formsets, change)
247+
for gmg in form.instance.genericmodegroup_set.all():
248+
if gmg.instrument_type:
249+
for instrument in gmg.instrument_type.instrument_set.all():
250+
update_heroic_instrument_capabilities(instrument)
251+
return finished
252+
203253

204254
@admin.register(OpticalElementGroup)
205255
class OpticalElementGroupAdmin(HardwareAdmin):
@@ -208,12 +258,31 @@ class OpticalElementGroupAdmin(HardwareAdmin):
208258
search_fields = ('name', 'type')
209259
list_filter = ('type',)
210260

261+
def save_related(self, request, form, formsets, change):
262+
# This is the best way to trigger on saving m2m admin relationships on the OpticalElementGroup,
263+
# like when its members change
264+
finished = super().save_related(request, form, formsets, change)
265+
for camera in form.instance.camera_set.all():
266+
for instrument in camera.instrument_set.all():
267+
update_heroic_instrument_capabilities(instrument)
268+
return finished
269+
211270

212271
@admin.register(OpticalElement)
213272
class OpticalElementAdmin(HardwareAdmin):
214273
list_display = ('name', 'code', 'schedulable')
215274
search_fields = ('name', 'code')
216275

276+
def save_related(self, request, form, formsets, change):
277+
# This is the best way to trigger on saving m2m admin relationships on the OpticalElement,
278+
# like when its memberships change
279+
finished = super().save_related(request, form, formsets, change)
280+
for oeg in form.instance.opticalelementgroup_set.all():
281+
for camera in oeg.camera_set.all():
282+
for instrument in camera.instrument_set.all():
283+
update_heroic_instrument_capabilities(instrument)
284+
return finished
285+
217286

218287
@admin.register(LogEntry)
219288
class LogEntryAdmin(admin.ModelAdmin):

configdb/hardware/apps.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
from django.apps import AppConfig
2+
from django.conf import settings
3+
4+
5+
def can_submit_to_heroic():
6+
return settings.HEROIC_API_URL and settings.HEROIC_API_TOKEN and settings.HEROIC_OBSERVATORY
7+
8+
9+
class HardwareConfig(AppConfig):
10+
name = 'configdb.hardware'
11+
12+
def ready(self):
13+
# Only load the heroic communication signals if heroic settings are set
14+
if can_submit_to_heroic():
15+
import configdb.hardware.signals.handlers # noqa
16+
super().ready()

configdb/hardware/heroic.py

Lines changed: 214 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,214 @@
1+
from django.conf import settings
2+
from urllib.parse import urljoin
3+
import requests
4+
import logging
5+
6+
from configdb.hardware.models import Instrument, Telescope, Site
7+
from configdb.hardware.apps import can_submit_to_heroic
8+
9+
10+
logger = logging.getLogger()
11+
12+
13+
def instrument_status_conversion(state: str):
14+
''' Converts instrument state to HEROIC instrument status
15+
'''
16+
if state == 'DISABLED' or state == 'MANUAL':
17+
return 'UNAVAILABLE'
18+
elif state == 'SCHEDULABLE':
19+
return 'SCHEDULABLE'
20+
else:
21+
return 'AVAILABLE'
22+
23+
24+
def telescope_status_conversion(telescope: Telescope):
25+
return 'SCHEDULABLE' if telescope.active and telescope.enclosure.active and telescope.enclosure.site.active else 'UNAVAILABLE'
26+
27+
28+
def heroic_site_id(site: Site):
29+
''' Extract a HEROIC id for the site.
30+
This concatenates the observatory.site for human readability
31+
'''
32+
return f"{settings.HEROIC_OBSERVATORY}.{site.code}"
33+
34+
35+
def heroic_telescope_id(telescope: Telescope):
36+
''' Extract a HEROIC id for the telescope
37+
This concatenates the observatory.site.telescope for human readability
38+
'''
39+
return f"{heroic_site_id(telescope.enclosure.site)}.{telescope.enclosure.code}-{telescope.code}"
40+
41+
42+
def heroic_instrument_id(instrument: Instrument):
43+
''' Extract a HEROIC id for the instrument
44+
This concatenates the observatory.site.telescope.instrument
45+
for human-readability.
46+
'''
47+
return f"{heroic_telescope_id(instrument.telescope)}.{instrument.code}"
48+
49+
50+
def heroic_optical_element_groups(instrument: Instrument):
51+
''' Puts the optical element groups of an instrument in the format for reporting to HEROIC
52+
'''
53+
optical_element_groups = {}
54+
for camera in instrument.science_cameras.all():
55+
for optical_element_group in camera.optical_element_groups.all():
56+
optical_element_groups[optical_element_group.type] = {'options': []}
57+
if optical_element_group.default:
58+
optical_element_groups[optical_element_group.type]['default'] = optical_element_group.default.code
59+
for optical_element in optical_element_group.optical_elements.all():
60+
optical_element_groups[optical_element_group.type]['options'].append({
61+
'id': optical_element.code,
62+
'name': optical_element.name,
63+
'schedulable': optical_element.schedulable
64+
})
65+
return optical_element_groups
66+
67+
68+
def heroic_operation_modes(instrument: Instrument):
69+
''' Puts the generic mode groups of an instrument in the format for reporting to HEROIC
70+
'''
71+
operation_modes = {}
72+
for generic_mode_group in instrument.instrument_type.mode_types.all():
73+
operation_modes[generic_mode_group.type.id] = {'options': []}
74+
if generic_mode_group.default:
75+
operation_modes[generic_mode_group.type.id]['default'] = generic_mode_group.default.code
76+
for mode in generic_mode_group.modes.all():
77+
operation_modes[generic_mode_group.type.id]['options'].append({
78+
'id': mode.code,
79+
'name': mode.name,
80+
'schedulable': mode.schedulable
81+
})
82+
return operation_modes
83+
84+
85+
def instrument_to_heroic_instrument_capabilities(instrument: Instrument):
86+
''' Extracts the current instrument capabilities of an instrument to send to HEROIC
87+
'''
88+
capabilities = {
89+
'instrument': heroic_instrument_id(instrument),
90+
'status': instrument_status_conversion(instrument.state),
91+
'optical_element_groups': heroic_optical_element_groups(instrument),
92+
'operation_modes': heroic_operation_modes(instrument)
93+
}
94+
return capabilities
95+
96+
97+
def telescope_to_heroic_telescope_properties(telescope: Telescope):
98+
''' Extracts the current telescope properties of a telescope to send to HEROIC
99+
'''
100+
telescope_payload = {
101+
'name': f"{telescope.name} - {telescope.enclosure.name}",
102+
'site': heroic_site_id(telescope.enclosure.site),
103+
'aperture': telescope.aperture,
104+
'latitude': telescope.lat,
105+
'longitude': telescope.long,
106+
'horizon': telescope.horizon,
107+
'negative_ha_limit': telescope.ha_limit_neg,
108+
'positive_ha_limit': telescope.ha_limit_pos,
109+
'zenith_blind_spot': telescope.zenith_blind_spot
110+
}
111+
return telescope_payload
112+
113+
114+
def send_to_heroic(api_endpoint: str, payload: dict, update: bool = False):
115+
''' Function to send data to HEROIC API endpoints
116+
'''
117+
headers = {'Authorization': f'Token {settings.HEROIC_API_TOKEN}'}
118+
url = urljoin(settings.HEROIC_API_URL, api_endpoint)
119+
if update:
120+
response = requests.patch(url, headers=headers, json=payload)
121+
else:
122+
response = requests.post(url, headers=headers, json=payload)
123+
logger.warning(response.json())
124+
response.raise_for_status()
125+
126+
127+
128+
def create_heroic_instrument(instrument: Instrument):
129+
''' Create a new instrument payload and send it to HEROIC
130+
'''
131+
if (instrument.telescope.enclosure.site.code not in settings.HEROIC_EXCLUDE_SITES and str(instrument.telescope) not in settings.HEROIC_EXCLUDE_TELESCOPES):
132+
instrument_payload = {
133+
'id': heroic_instrument_id(instrument),
134+
'name': f"{instrument.instrument_type.name} - {instrument.code}",
135+
'telescope': heroic_telescope_id(instrument.telescope),
136+
'available': True
137+
}
138+
try:
139+
send_to_heroic('instruments/', instrument_payload)
140+
except Exception as e:
141+
logger.error(f'Failed to create heroic instrument {str(instrument)}: {repr(e)}')
142+
143+
144+
def update_heroic_instrument_capabilities(instrument: Instrument):
145+
''' Send the current instrument capabilities of an instrument to HEROIC
146+
if it is not DISABLED and heroic is set up in settings.py
147+
'''
148+
if can_submit_to_heroic() and instrument.state != 'DISABLED' and instrument.telescope.enclosure.site.code not in settings.HEROIC_EXCLUDE_SITES and str(instrument.telescope) not in settings.HEROIC_EXCLUDE_TELESCOPES:
149+
capabilities = instrument_to_heroic_instrument_capabilities(instrument)
150+
try:
151+
send_to_heroic('instrument-capabilities/', capabilities)
152+
except Exception as e:
153+
logger.error(f'Failed to create heroic instrument {str(instrument)} capability update: {repr(e)}')
154+
155+
156+
def create_heroic_telescope(telescope: Telescope):
157+
''' Create a new telescope payload and send it to HEROIC
158+
'''
159+
if telescope.enclosure.site.code not in settings.HEROIC_EXCLUDE_SITES and str(telescope) not in settings.HEROIC_EXCLUDE_TELESCOPES:
160+
telescope_payload = telescope_to_heroic_telescope_properties(telescope)
161+
telescope_payload['id'] = heroic_telescope_id(telescope)
162+
telescope_payload['status'] = telescope_status_conversion(telescope)
163+
if telescope_payload['status'] != 'SCHEDULABLE':
164+
telescope_payload['reason'] = 'Telescope is currently marked as inactive to prevent usage'
165+
try:
166+
send_to_heroic('telescopes/', telescope_payload)
167+
except Exception as e:
168+
logger.error(f'Failed to create heroic telescope {str(telescope)}: {repr(e)}')
169+
170+
171+
def update_heroic_telescope_properties(telescope: Telescope):
172+
''' Send updated telescope properties to HEROIC when they change
173+
'''
174+
if telescope.enclosure.site.code not in settings.HEROIC_EXCLUDE_SITES and str(telescope) not in settings.HEROIC_EXCLUDE_TELESCOPES:
175+
telescope_update_payload = telescope_to_heroic_telescope_properties(telescope)
176+
try:
177+
send_to_heroic(f'telescopes/{heroic_telescope_id(telescope)}/', telescope_update_payload, update=True)
178+
except Exception as e:
179+
logger.error(f'Failed to update heroic telescope {str(telescope)}: {repr(e)}')
180+
181+
182+
def site_to_heroic_site_properties(site: Site):
183+
''' Extracts the current site properties of a site to send to HEROIC
184+
'''
185+
site_payload = {
186+
'name': site.name,
187+
'observatory': settings.HEROIC_OBSERVATORY,
188+
'elevation': site.elevation,
189+
'timezone': site.tz
190+
}
191+
return site_payload
192+
193+
194+
def create_heroic_site(site: Site):
195+
''' Create a new site payload and send it to HEROIC
196+
'''
197+
if site.code not in settings.HEROIC_EXCLUDE_SITES:
198+
site_payload = site_to_heroic_site_properties(site)
199+
site_payload['id'] = heroic_site_id(site)
200+
try:
201+
send_to_heroic('sites/', site_payload)
202+
except Exception as e:
203+
logger.error(f'Failed to create heroic site {str(site)}: {repr(e)}')
204+
205+
206+
def update_heroic_site(site: Site):
207+
''' Send updated site properties to HEROIC when they change
208+
'''
209+
if site.code not in settings.HEROIC_EXCLUDE_SITES:
210+
site_payload = site_to_heroic_site_properties(site)
211+
try:
212+
send_to_heroic(f'sites/{heroic_site_id(site)}/', site_payload, update=True)
213+
except Exception as e:
214+
logger.error(f'Failed to update heroic site {str(site)}: {repr(e)}')

0 commit comments

Comments
 (0)