diff --git a/Docker/lofar-ci/Dockerfile_ci_sas b/Docker/lofar-ci/Dockerfile_ci_sas index d5d9b7ce6e4cb7cdc2eb0b4c5fead89d55ae6a5f..e756c0d4799af4316fd89f27a7ad42227c0fc3fd 100644 --- a/Docker/lofar-ci/Dockerfile_ci_sas +++ b/Docker/lofar-ci/Dockerfile_ci_sas @@ -16,7 +16,7 @@ RUN yum erase -y postgresql postgresql-server postgresql-devel && \ cd /bin && ln -s /usr/pgsql-9.6/bin/initdb && ln -s /usr/pgsql-9.6/bin/postgres ENV PATH /usr/pgsql-9.6/bin:$PATH -RUN pip3 install cython kombu lxml requests pygcn xmljson mysql-connector-python python-dateutil Django==3.0.9 djangorestframework djangorestframework-xml ldap==1.0.2 flask fabric coverage python-qpid-proton PyGreSQL numpy h5py psycopg2 testing.postgresql Flask-Testing scipy Markdown django-filter python-ldap python-ldap-test ldap3 djangorestframework django-jsonforms django-json-widget django-jsoneditor drf-yasg flex swagger-spec-validator django-auth-ldap mozilla-django-oidc jsonschema comet pyxb==1.2.5 graphviz isodate +RUN pip3 install cython kombu lxml requests pygcn xmljson mysql-connector-python python-dateutil Django==3.0.9 djangorestframework djangorestframework-xml ldap==1.0.2 flask fabric coverage python-qpid-proton PyGreSQL numpy h5py psycopg2 testing.postgresql Flask-Testing scipy Markdown django-filter python-ldap python-ldap-test ldap3 djangorestframework django-jsonforms django-json-widget django-jsoneditor drf-yasg flex swagger-spec-validator django-auth-ldap mozilla-django-oidc jsonschema comet pyxb==1.2.5 graphviz isodate astropy # Note: nodejs now comes with npm, do not install the npm package separately, since that will be taken from the epel repo and is conflicting. RUN echo "Installing Nodejs packages..." && \ diff --git a/LTA/sip/lib/CMakeLists.txt b/LTA/sip/lib/CMakeLists.txt index a9851a135a5f82c62c0110b5d802d191a95a2fe0..7cfd5468d9f8430570aeea7bb77a6e4c52b0495e 100644 --- a/LTA/sip/lib/CMakeLists.txt +++ b/LTA/sip/lib/CMakeLists.txt @@ -14,6 +14,7 @@ set(_py_files constants.py visualizer.py query.py + station_coordinates.py ) @@ -25,6 +26,7 @@ set(resource_files python_install(${_py_files} DESTINATION lofar/lta/sip) + install(FILES ${resource_files} DESTINATION ${PYTHON_INSTALL_DIR}/lofar/lta/sip COMPONENT ${lower_package_name}) diff --git a/LTA/sip/lib/siplib.py b/LTA/sip/lib/siplib.py index bb3c6238a14a19117ee2d5379f838ddb6b04f66c..4f89a4fe91f5552972e9b43c0cc4afe903d9d9fc 100644 --- a/LTA/sip/lib/siplib.py +++ b/LTA/sip/lib/siplib.py @@ -28,6 +28,7 @@ from . import ltasip import pyxb from . import constants +from . import station_coordinates import os import uuid import xml.dom.minidom @@ -38,8 +39,6 @@ import logging logger = logging.getLogger(__name__) VERSION = "SIPlib 0.4" -d = os.path.dirname(os.path.realpath(__file__)) -STATION_CONFIG_PATH = d+'/station_coordinates.conf' ltasip.Namespace.setPrefix('sip') # todo: create docstrings for everything. @@ -144,30 +143,28 @@ class Station(): __afield1=None __afield2=None - with open(STATION_CONFIG_PATH, 'r') as f: - for line in f.readlines(): - if line.strip(): - field_coords = eval("dict("+line+")") # literal_eval does not accept dict definition via constructor. Make sure config file is not writable to prevent code execution! - for type in antennafieldtypes: - if field_coords["name"] == name+"_"+type: - __afield=AntennafieldXYZ( - type=type, + station_coords = station_coordinates.parse_station_coordinates() + for atype in antennafieldtypes: + if name+"_"+atype in station_coords.keys(): + field_coords = station_coords[name+"_"+atype] + __afield=AntennafieldXYZ( + type=atype, coordinate_system=field_coords["coordinate_system"], coordinate_unit=constants.LENGTHUNIT_M, # Does this make sense? I have to give a lenght unit accoridng to the XSD, but ICRF should be decimal degrees?! coordinate_x=field_coords["x"], coordinate_y=field_coords["y"], coordinate_z=field_coords["z"]) - if not __afield1: - __afield1=__afield - elif not __afield2: - __afield2=__afield + if not __afield1: + __afield1=__afield + elif not __afield2: + __afield2=__afield if not __afield1: - raise Exception("no matching coordinates found for station:", name,"and fields",str(antennafieldtypes)) + raise Exception("no matching coordinates found for station:", name, "and fields", str(antennafieldtypes)) - if name.startswith( 'CS' ): + if name.startswith('CS'): sttype = "Core" - elif name.startswith( "RS" ): + elif name.startswith("RS"): sttype = "Remote" else: sttype = "International" diff --git a/LTA/sip/lib/station_coordinates.py b/LTA/sip/lib/station_coordinates.py new file mode 100644 index 0000000000000000000000000000000000000000..f2952a203d2af5ee5578342eac1af1706c41662c --- /dev/null +++ b/LTA/sip/lib/station_coordinates.py @@ -0,0 +1,44 @@ +#!/usr/bin/env python3 + +# This module provides functions for easy creation of a Lofar LTA SIP document. +# It builds upon a Pyxb-generated API from the schema definition, which is very clever but hard to use, since +# the arguments in class constructors and functions definitions are not verbose and there is no intuitive way +# to determine the mandatory and optional elements to create a valid SIP document. This module is designed to +# provide easy-to-use functions that bridges this shortcoming of the Pyxb API. +# +# Usage: Import module. Create an instance of Sip. +# Add elements through the Sip.add_X functions. Many require instances of other classes of the module. +# call getprettyxml() and e.g. save to disk. +# +# Note on validation: From construction through every addition, the SIP should remain valid (or throw an error +# that clearly points out where e.g. a given value does not meet the restrictions of the SIP schema. +# +# Note on code structure: This has to be seen as a compromise between elegant and maintainable code with well- +# structured inheritance close to the schema definition on the one hand, and something more straightforward to use, +# with flatter hierarchies on the other hand. +# +# Note on parameter maps: The ...Map objects are helper objects to create dictionaries for the commonly used +# constructor arguments of several other objects. This could alternatively also be implemented via inheritance from +# a supertype, and indeed is solved like this in the pyxb code. However, this then requires the use of an argument +# list pointer, which hides the list of required and optional arguments from the user. Alternatively, all arguments +# have to be mapped in all constructors repeatedly, creating lots of boilerplate code. This is the nicest approach +# I could think of that keeps the whole thing reasonably maintainable AND usable. + +import os +d = os.path.dirname(os.path.realpath(__file__)) +STATION_CONFIG_PATH = d+'/station_coordinates.conf' + + +def parse_station_coordinates() -> dict: + """ + :return: a dict mapping station field name, e.g. "CS002_LBA", to a dict containing ITRF coordinates + """ + station_coordinates = {} + with open(STATION_CONFIG_PATH, 'r') as f: + for line in f.readlines(): + if line.strip(): + field_coords = eval("dict(" + line + ")") # literal_eval does not accept dict definition via constructor. Make sure config file is not writable to prevent code execution! + station_coordinates[field_coords.pop("name")] = field_coords + return station_coordinates + + diff --git a/SAS/TMSS/src/tmss/tmssapp/CMakeLists.txt b/SAS/TMSS/src/tmss/tmssapp/CMakeLists.txt index 47a6fc110c6e09c09bf272f1ee0f0f04a5a65407..3c5a89286a7c38d5b12fbb41aca524553cf443d7 100644 --- a/SAS/TMSS/src/tmss/tmssapp/CMakeLists.txt +++ b/SAS/TMSS/src/tmss/tmssapp/CMakeLists.txt @@ -10,6 +10,7 @@ set(_py_files validation.py subtasks.py tasks.py + conversions.py ) python_install(${_py_files} diff --git a/SAS/TMSS/src/tmss/tmssapp/conversions.py b/SAS/TMSS/src/tmss/tmssapp/conversions.py new file mode 100644 index 0000000000000000000000000000000000000000..e851ecbe396955955f1ae9dc1f32890cb819b53d --- /dev/null +++ b/SAS/TMSS/src/tmss/tmssapp/conversions.py @@ -0,0 +1,41 @@ +from astropy.time import Time +import astropy.units +from lofar.lta.sip import station_coordinates +from datetime import datetime +from astropy.coordinates.earth import EarthLocation + + +def local_sidereal_time_for_utc_and_station(timestamp: datetime = None, + station: str = 'CS002', + field: str = 'LBA', + kind: str = "apparent"): + """ + calculate local sidereal time for given utc time and station + :param timestamp: timestamp as datetime object + :param station: station name + :param field: antennafield, 'LBA' or 'HBA' + :param kind: 'mean' or 'apparent' + :return: + """ + if timestamp is None: + timestamp = datetime.utcnow() + station_coords = station_coordinates.parse_station_coordinates() + field_coords = station_coords["%s_%s" % (station, field)] + location = EarthLocation.from_geocentric(x=field_coords['x'], y=field_coords['y'], z=field_coords['z'], unit=astropy.units.m) + return local_sidereal_time_for_utc_and_longitude(timestamp=timestamp, longitude=location.lon.to_string(decimal=True), kind=kind) + + +def local_sidereal_time_for_utc_and_longitude(timestamp: datetime = None, + longitude: float = 6.8693028, + kind: str = "apparent"): + """ + :param timestamp: timestamp as datetime object + :param longitude: decimal longitude of observer location (defaults to CS002 LBA center) + :param kind: 'mean' or 'apparent' + :return: + """ + if timestamp is None: + timestamp = datetime.utcnow() + t = Time(timestamp, format='datetime', scale='utc') + return t.sidereal_time(kind=kind, longitude=longitude) + diff --git a/SAS/TMSS/src/tmss/tmssapp/views.py b/SAS/TMSS/src/tmss/tmssapp/views.py index 4614c940953d2a277b00cf1eb0589ef6efb1edd5..37f7a9cd70e92da9803737dd51b8cd19577e03b9 100644 --- a/SAS/TMSS/src/tmss/tmssapp/views.py +++ b/SAS/TMSS/src/tmss/tmssapp/views.py @@ -5,7 +5,9 @@ from django.shortcuts import get_object_or_404, render from lofar.sas.tmss.tmss.tmssapp import models from lofar.common.json_utils import get_default_json_object_for_schema from lofar.sas.tmss.tmss.tmssapp.adapters.parset import convert_to_parset - +from datetime import datetime +import dateutil.parser +from lofar.sas.tmss.tmss.tmssapp.conversions import local_sidereal_time_for_utc_and_station, local_sidereal_time_for_utc_and_longitude def subtask_template_default_specification(request, subtask_template_pk:int): subtask_template = get_object_or_404(models.SubtaskTemplate, pk=subtask_template_pk) @@ -23,11 +25,41 @@ def subtask_parset(request, subtask_pk:int): subtask = get_object_or_404(models.Subtask, pk=subtask_pk) parset = convert_to_parset(subtask) return HttpResponse(str(parset), content_type='text/plain') - + + def index(request): return render(request, os.path.join(os.path.dirname(os.path.dirname(os.path.realpath(__file__))), '../../frontend','tmss_webapp/build/index.html')) #return render(request, "../../../frontend/frontend_poc/build/index.html") + def task_specify_observation(request, pk=None): task = get_object_or_404(models.TaskDraft, pk=pk) return HttpResponse("response", content_type='text/plain') + + +def utc(request): + return HttpResponse(datetime.utcnow().isoformat(), content_type='text/plain') + + +def lst(request): + # Handling optional parameters via django paths in urls.py is a pain, we access them on the request directly instead. + timestamp = request.GET.get('timestamp', None) + station = request.GET.get('station', None) + longitude = request.GET.get('longitude', None) + + # conversions + if timestamp: + timestamp = dateutil.parser.parse(timestamp) # isot to datetime + if longitude: + longitude = float(longitude) + + if station: + lst_lon = local_sidereal_time_for_utc_and_station(timestamp, station) + elif longitude: + lst_lon = local_sidereal_time_for_utc_and_longitude(timestamp, longitude) + else: + # fall back to defaults + lst_lon = local_sidereal_time_for_utc_and_station(timestamp) + + # todo: do we want to return a dict, so users can make sure their parameters were parsed correctly instead? + return HttpResponse(str(lst_lon), content_type='text/plain') \ No newline at end of file diff --git a/SAS/TMSS/src/tmss/urls.py b/SAS/TMSS/src/tmss/urls.py index 53146045e08986f1cb8930e993b04129df909610..9b7895326ffebfad99aa9740faa0e649fdc6f608 100644 --- a/SAS/TMSS/src/tmss/urls.py +++ b/SAS/TMSS/src/tmss/urls.py @@ -28,6 +28,8 @@ from rest_framework.documentation import include_docs_urls from drf_yasg.views import get_schema_view from drf_yasg import openapi +from datetime import datetime + # # Django style patterns # @@ -54,7 +56,9 @@ urlpatterns = [ path('docs/', include_docs_urls(title='TMSS API')), re_path(r'^swagger(?P<format>\.json|\.yaml)$', swagger_schema_view.without_ui(cache_timeout=0), name='schema-json'), path('swagger/', swagger_schema_view.with_ui('swagger', cache_timeout=0), name='schema-swagger-ui'), - path('redoc/', swagger_schema_view.with_ui('redoc', cache_timeout=0), name='schema-redoc') + path('redoc/', swagger_schema_view.with_ui('redoc', cache_timeout=0), name='schema-redoc'), + path(r'util/utc', views.utc, name="system-utc"), + path(r'util/lst', views.lst, name="conversion-lst"), ] diff --git a/SAS/TMSS/test/CMakeLists.txt b/SAS/TMSS/test/CMakeLists.txt index b19ddcd546e283f0e176ecaf57711bb3b8b8f03c..769fce231ac3bc18470ae3c974456d4ec089ff68 100644 --- a/SAS/TMSS/test/CMakeLists.txt +++ b/SAS/TMSS/test/CMakeLists.txt @@ -32,6 +32,7 @@ if(BUILD_TESTING) lofar_add_test(t_adapter) lofar_add_test(t_tasks) lofar_add_test(t_scheduling) + lofar_add_test(t_conversions) # To get ctest running file(COPY testdata DESTINATION ${CMAKE_CURRENT_BINARY_DIR}) diff --git a/SAS/TMSS/test/t_conversions.py b/SAS/TMSS/test/t_conversions.py new file mode 100755 index 0000000000000000000000000000000000000000..ccd4025f6c4c21a43d63f5ccb6a55c3b764f0963 --- /dev/null +++ b/SAS/TMSS/test/t_conversions.py @@ -0,0 +1,133 @@ +#!/usr/bin/env python3 + +# Copyright (C) 2018 ASTRON (Netherlands Institute for Radio Astronomy) +# P.O. Box 2, 7990 AA Dwingeloo, The Netherlands +# +# This file is part of the LOFAR software suite. +# The LOFAR software suite is free software: you can redistribute it and/or +# modify it under the terms of the GNU General Public License as published +# by the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# The LOFAR software suite is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along +# with the LOFAR software suite. If not, see <http://www.gnu.org/licenses/>. + +# $Id: $ + +import os +import unittest +import datetime +import logging +import requests +import dateutil.parser +import astropy.coordinates + +logger = logging.getLogger(__name__) +logging.basicConfig(format='%(asctime)s %(levelname)s %(message)s', level=logging.INFO) +from lofar.sas.tmss.tmss.tmssapp.conversions import local_sidereal_time_for_utc_and_station, local_sidereal_time_for_utc_and_longitude + +# Do Mandatory setup step: +# use setup/teardown magic for tmss test database, ldap server and django server +# (ignore pycharm unused import statement, python unittests does use at RunTime the tmss_test_environment_unittest_setup module) +from lofar.sas.tmss.test.tmss_test_environment_unittest_setup import * + +class SiderealTime(unittest.TestCase): + + def test_local_sidereal_time_for_utc_and_longitude_returns_correct_result(self): + # test result against known correct value + lst = local_sidereal_time_for_utc_and_longitude(timestamp=datetime.datetime(year=2020, month=1, day=1, hour=12, minute=0, second=0)) + self.assertEqual(str(lst), '19h09m54.9567s') + + def test_local_sidereal_time_for_utc_and_longitude_considers_timestamp(self): + # test that the results differ for different timestamps + lst1 = local_sidereal_time_for_utc_and_longitude(timestamp=datetime.datetime(year=2020, month=1, day=1, hour=12, minute=0, second=0)) + lst2 = local_sidereal_time_for_utc_and_longitude(timestamp=datetime.datetime(year=2020, month=1, day=2, hour=12, minute=0, second=0)) + self.assertNotEqual(str(lst1), str(lst2)) + + def test_local_sidereal_time_for_utc_and_longitude_considers_longitude(self): + # test that the results differ for different longitudes + lst1 = local_sidereal_time_for_utc_and_longitude(timestamp=datetime.datetime(year=2020, month=1, day=1, hour=12, minute=0, second=0), longitude=6.789) + lst2 = local_sidereal_time_for_utc_and_longitude(timestamp=datetime.datetime(year=2020, month=1, day=1, hour=12, minute=0, second=0), longitude=6.123) + self.assertNotEqual(str(lst1), str(lst2)) + + def test_local_sidereal_time_for_utc_and_station_returns_correct_result(self): + # assert result against known correct value + lst = local_sidereal_time_for_utc_and_station(timestamp=datetime.datetime(year=2020, month=1, day=1, hour=12, minute=0, second=0)) + self.assertEqual(str(lst), '19h09m55.0856s') + + def test_local_sidereal_time_for_utc_and_station_considers_timestamp(self): + # test that the results differ for different timestamps + lst1 = local_sidereal_time_for_utc_and_station(timestamp=datetime.datetime(year=2020, month=1, day=1, hour=12, minute=0, second=0)) + lst2 = local_sidereal_time_for_utc_and_station(timestamp=datetime.datetime(year=2020, month=1, day=2, hour=12, minute=0, second=0)) + self.assertNotEqual(str(lst1), str(lst2)) + + def test_local_sidereal_time_for_utc_and_station_considers_station(self): + # test that the results differ for different stations + lst1 = local_sidereal_time_for_utc_and_station(timestamp=datetime.datetime(year=2020, month=1, day=1, hour=12, minute=0, second=0), station="CS002") + lst2 = local_sidereal_time_for_utc_and_station(timestamp=datetime.datetime(year=2020, month=1, day=1, hour=12, minute=0, second=0), station="DE602") + self.assertNotEqual(str(lst1), str(lst2)) + + +class UtilREST(unittest.TestCase): + + def test_util_utc_returns_timestamp(self): + + # assert local clock differs not too much from returned TMSS system clock + r = requests.get(BASE_URL + '/util/utc', auth=AUTH) + self.assertEqual(r.status_code, 200) + returned_datetime = dateutil.parser.parse(r.content.decode('utf8')) + current_datetime = datetime.datetime.utcnow() + delta = abs((returned_datetime - current_datetime).total_seconds()) + self.assertTrue(delta < 60.0) + + def test_util_lst_returns_longitude(self): + + # assert returned value is a parseable hms value + for query in ['/util/lst', + '/util/lst?timestamp=2020-01-01T12:00:00', + '/util/lst?timestamp=2020-01-01T12:00:00&longitude=54.321', + '/util/lst?timestamp=2020-01-01T12:00:00&station=DE609']: + r = requests.get(BASE_URL + query, auth=AUTH) + self.assertEqual(r.status_code, 200) + lon_str = r.content.decode('utf8') + lon_obj = astropy.coordinates.Longitude(lon_str) + self.assertEqual(str(lon_obj), lon_str) + + def test_util_lst_considers_timestamp(self): + + # assert returned value matches known result for given timestamp + r = requests.get(BASE_URL + '/util/lst?timestamp=2020-01-01T12:00:00', auth=AUTH) + self.assertEqual(r.status_code, 200) + lon_str = r.content.decode('utf8') + self.assertEqual('19h09m55.0856s', lon_str) + + def test_util_lst_considers_station(self): + + # assert returned value differs when a different station is given + r1 = requests.get(BASE_URL + '/util/lst', auth=AUTH) + r2 = requests.get(BASE_URL + '/util/lst?station=DE602', auth=AUTH) + self.assertEqual(r1.status_code, 200) + self.assertEqual(r2.status_code, 200) + lon_str1 = r1.content.decode('utf8') + lon_str2 = r2.content.decode('utf8') + self.assertNotEqual(lon_str1, lon_str2) + + def test_util_lst_considers_longitude(self): + # assert returned value differs when a different station is given + r1 = requests.get(BASE_URL + '/util/lst', auth=AUTH) + r2 = requests.get(BASE_URL + '/util/lst?longitude=12.345', auth=AUTH) + self.assertEqual(r1.status_code, 200) + self.assertEqual(r2.status_code, 200) + lon_str1 = r1.content.decode('utf8') + lon_str2 = r2.content.decode('utf8') + self.assertNotEqual(lon_str1, lon_str2) + + +if __name__ == "__main__": + os.environ['TZ'] = 'UTC' + unittest.main() diff --git a/SAS/TMSS/test/t_conversions.run b/SAS/TMSS/test/t_conversions.run new file mode 100755 index 0000000000000000000000000000000000000000..d7c74389715a9cd50f3c36c5e406607f77c048f2 --- /dev/null +++ b/SAS/TMSS/test/t_conversions.run @@ -0,0 +1,6 @@ +#!/bin/bash + +# Run the unit test +source python-coverage.sh +python_coverage_test "*tmss*" t_conversions.py + diff --git a/SAS/TMSS/test/t_conversions.sh b/SAS/TMSS/test/t_conversions.sh new file mode 100755 index 0000000000000000000000000000000000000000..c95892264d5c49a9a76e274e0b99c308fe8ae29c --- /dev/null +++ b/SAS/TMSS/test/t_conversions.sh @@ -0,0 +1,3 @@ +#!/bin/sh + +./runctest.sh t_conversions \ No newline at end of file