# -*- coding: utf-8 -*-
"""
Copyright © 2017-2018 The University of New South Wales
Permission is hereby granted, free of charge, to any person obtaining a copy of
this software and associated documentation files (the "Software"), to deal in
the Software without restriction, including without limitation the rights to use,
copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
Software, and to permit persons to whom the Software is furnished to do so,
subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
Except as contained in this notice, the name or trademarks of a copyright holder
shall not be used in advertising or otherwise to promote the sale, use or other
dealings in this Software without prior written authorization of the copyright
holder.
UNSW is a trademark of The University of New South Wales.
Created on Thu May 25 14:08:34 2017
@author: kjetil
"""
import ephem
from datetime import datetime
import time
from math import pi
import pandas as pd
from pandas import DataFrame
import threading
import angles
import sys
from rpc import RPCServer
from utils import Error, RegularCallback, Defaults, wait_loop, conv_time, UTCLogFormatterHTML, AbortAllException, safe_sleep
from monitoring import Monitor
from protocols.protocolbase import ProtocolBase
from propagator import Propagator
from hardware import RotatorBase, RadioBase, DummyRotator
from database import CommsLog, PassDb
#
# Configure logging
#
import logging
log = logging.getLogger('libgs-log')
log.addHandler(logging.NullHandler())
try:
#### For plotting
import matplotlib.pyplot as plt
import numpy as np
import io
_HAS_MPL = True
except:
_HAS_MPL = False
def _mpl_make_waterfall_jpg(radios, recordings):
"""
Helper function to create a waterfall plot to store in the databases
"""
#
# Convert recorded spectra to images.
#
if not _HAS_MPL:
log.debug("Cannot create waterfall - missing packages. Install matplot + pillow to make this work")
return None
dpi = Defaults.WFALL_JPG_DPI
fig_h = Defaults.WFALL_JPG_HEIGHT
fig_w = Defaults.WFALL_JPG_WIDTH_EXTRA + Defaults.WFALL_JPG_WIDTH*len(recordings)
plt.figure(figsize=(fig_w/dpi, fig_h/dpi), dpi=dpi)
left_a = None
dv = None
for k,radrec in enumerate(recordings):
a = plt.subplot(1,len(radios), k+1)
a.set_title(radios[k])
if k == 0:
left_a = a
try:
fv,dv,sp = radrec.result()
except:
a.tick_params(axis='both', which='both', bottom=False, top=False, left=False, right=False,
labelbottom=False, labeltop=False, labelleft=False, labelright=False)
continue
# Get the bad specra (all zeroes)
bad_spec = np.prod(sp,1) == 0
minval = np.min(sp)
sp[bad_spec, :] = minval
f0 = np.mean(fv)
a.imshow(sp, extent=((fv[0]-f0)*1000, (fv[-1]-f0)*1000, (dv[-1] - dv[0]).total_seconds(), 0), interpolation="none", cmap=Defaults.WFALL_JPG_COLORMAP)
a.set_aspect("auto", adjustable="box")
a.set_xlabel("Frequency (kHz + {:.0f})".format(f0*1000))
log.info("Radio {}. {} spectra recorded".format(radios[k], sp.shape[0]))
# Leftmost plot should have a ylabel indicating the start of the pass
if left_a is not None and dv is not None:
left_a.set_ylabel("Time (sec + {})".format(conv_time(dv[0], to="datetime").strftime('%Y-%m-%d %H:%M:%S Z')))
buf = io.BytesIO()
plt.savefig(buf, format="jpg", dpi=dpi/Defaults.WFALL_JPG_OUT_SCALE)
image_data = bytearray(buf.getvalue())
plt.close("all")
buf.close()
return image_data
def _compute_antenna_points(pdat, max_offp):
ant_p = DataFrame(columns=pdat.columns)
ant_p = ant_p.append(pdat.iloc[0])
R2D = 180.0/pi
D2R = pi/180.0
for k,r in pdat.iterrows():
s = angles.sep(ant_p.iloc[-1].az*D2R, ant_p.iloc[-1].el*D2R, r.az*D2R, r.el*D2R)*R2D
if (s >= max_offp):
ant_p = ant_p.append(r)
if len(ant_p) > 1:
t = ant_p.index[:-1:2]
ant_p = ant_p.iloc[1::2]
ant_p.index =t
elif len(ant_p) == 0:
raise Error("Failed to compute any antenna points")
return ant_p
[docs]class GroundStationBase(object):
"""
Base class for ground stations.
It implements the necessary interface with exceptions being thrown.
By inheriting from this class when making new ground stations you guarantee
that invalid implementation raises exceptions.
"""
MONITOR_SAVE_DT_TRACK = Defaults.MONITOR_SAVE_DT_TRACK
MONITOR_SAVE_DT_NOTRACK = Defaults.MONITOR_SAVE_DT_NOTRACK
############################
#
# Properties
#
############################
_radios = []
_rotators = []
_protocol = None
_propagator= None
def __del__(self):
for r in self._radios:
del r
for r in self._rotators:
del r
@property
def radios(self):
return self._radios
@radios.setter
def radios(self, r):
if not isinstance(r, list):
raise Error("radios must be a list of Radio objects, got %s"%(type(r)))
for rr in r:
if not isinstance(rr, RadioBase):
raise Error("radios must be a list of Radio objects, got a %s"%(type(rr)))
self._radios = r
@property
def rotators(self):
return self._rotators
@rotators.setter
def rotators(self, r):
if not isinstance(r, list):
raise Error("rotators must be a list of Rotator objects, got %s"%(type(r)))
for rr in r:
if not isinstance(rr, RotatorBase):
raise Error("rotators must be a list of Rotator objects, got a %s"%(type(rr)))
self._rotators = r
@property
def protocols(self):
return self._protocols
@protocols.setter
def protocols(self, p):
if not isinstance(p, list):
raise Error("protocols must be a list of Protocol objects, got %s"%(type(p)))
for pp in p:
if not isinstance(pp, ProtocolBase):
raise Error("protocol is of invalid type, got %s"%type(p))
self._protocols = p
@property
def protocol(self):
return self._protocol
@protocol.setter
def protocol(self, p):
bad_input = True
try:
if (p is None) or isinstance(p, ProtocolBase):
bad_input = False
self._protocol = p
elif isinstance(p, basestring):
for p1 in self._protocols:
if p1.name == p:
bad_input = False
self._protocol = p1
log.debug('Protocol has been set to {}'.format(self._protocol))
finally:
if bad_input:
raise Error("protocol must be a valid Protocol class or a string in '%s'"%([pp.name for pp in self.protocols]))
@property
def propagator(self):
return self._propagator
@propagator.setter
def propagator(self, p):
if p is not None and not isinstance(p, Propagator):
raise Error("propagator is of invalid type, got %s"%(type(p)))
self._propagator = p
@property
def monitor(self):
if hasattr(self, '_monitor'):
return self._monitor
else:
return None
@monitor.setter
def monitor(self, m):
if not isinstance(m, Monitor):
raise Error("Error setting monitor, expected object of type Monitor, got {}".format(m))
else:
self._monitor = m
# self._monitor_logger = IntervalCallback(self._monitor_logger_cb, dt = self.MONITOR_SAVE_DT_NOTRACK)
self._monitor.add_callback(self._monitor_logger_cb)
@property
def rpcserver(self):
if hasattr(self, '_rpcserver'):
return self._rpcserver
else:
return None
@rpcserver.setter
def rpcserver(self, s):
if s is None:
return
elif not isinstance(s, RPCServer):
raise Error("Error setting rpcserver, expected object of type RPCServer, got {}".format(s))
else:
self._rpcserver = s
self._rpcserver.register_function(lambda prot,self=self: setattr(self, 'protocol', prot), "set_protocol")
self._rpcserver.register_function(self.start_track)
self._rpcserver.register_function(self.stop_track)
self._rpcserver.register_function(self.do_action)
self._rpcserver.register_function(self.set_azel)
self._rpcserver.register_function(self.stow)
self._rpcserver.register_function(self.transmit)
self._rpcserver.register_function(self.init_rx)
self._rpcserver.register_function(self.init_rxtx)
self._rpcserver.register_function(self.terminate)
self._rpcserver.register_function(self.get_azel)
self._rpcserver.register_introspection_functions()
log.info("Registered RPC interface to ground station on {}".format(s.uri))
@property
def scheduler(self):
return self._scheduler if hasattr(self, '_scheduler') else None
@scheduler.setter
def scheduler(self, sch):
if self.state.state != self.state.IDLE:
raise Exception("groundstation.scheduler: Cannot attach a new scheduler to groundstation since the state is currently not IDLE. It is {}".format(self.state.state))
self._scheduler = sch
######################
#
# Methods
#
######################
[docs] def start_track(self, nid_or_pdata, **kwargs):
"""
Non-blocking tracking. The antenna will be pointed as
required but returning control while doing so.
It is implemented by spinning the track function off into a separate thread
"""
self._pthr_track = threading.Thread(target=self.track, args=(nid_or_pdata,), kwargs=kwargs)
self._pthr_track.start()
[docs] def stop_track(self, block=False):
"""
Stop tracking satellite.
"""
if self._stop_track:
log.debug("stop_track has already been called. Not doing anything.")
return #<-- dont do it twice
self._stop_track = True
if block:
self._pthr_track.join()
[docs] def terminate(self):
"""
Wrapper for protocol.terminate()
"""
if not self.protocol:
raise Error ("Error: groundstation.terminate. No protocol have been connected to Ground Station.")
else:
self.protocol.terminate()
[docs] def init_rx(self):
"""
Wrapper for protocol.init_rx()
"""
if not self.protocol:
raise Error("Error: groundstation.init_rx. No protocol have been connected to Ground Station.")
else:
self.protocol.init_rx()
[docs] def init_rxtx(self):
"""
Wrapper for protocol.init_rxtx()
"""
if not self.protocol:
raise Error ("Error: groundstation.init_rxtx. No protocol have been connected to Ground Station.")
else:
self.protocol.init_rxtx()
[docs] def get_azel(self):
result = []
if len(self.rotators) == 0:
raise Error ("Error: groundstation.get_azel. No rotators have been connected to Ground Station.")
for r in self.rotators:
result.extend([r.get_azel()])
return result
[docs] def set_azel(self, az, el, block=False):
if self.rotators is None:
raise Error("GroundStation.set_azel called but no rotators available")
TIMEOUT = 60
for r in self.rotators:
r.set_azel(az,el, block=False)
TIMEOUT = r.SLEW_TIMEOUT if r.SLEW_TIMEOUT > TIMEOUT else TIMEOUT
if block:
if wait_loop(lambda: all([r.in_pos(az, el) for r in self.rotators]), timeout=TIMEOUT) is None:
log.error("Timeout ({} sec) waiting for rotators to position themselves".format(TIMEOUT))
[docs] def stow(self, block=False):
if self.rotators is None:
raise Error("GroundStation.stow called but no rotators available")
TIMEOUT = 60
for r in self.rotators:
r.stow(block=False)
if block:
t0 = time.time()
while not all([r.in_pos(r.STOWED_AZ, r.STOWED_EL) for r in self.rotators]):
if time.time() - t0 > TIMEOUT:
log.error("Timeout waiting for rotators to position themselves")
break
############################
#
# Interface methods (shall be overloaded)
#
############################
[docs] def set_rangerate(self, range_rate):
raise Error("GroundStation.set_rangerate interface not implemented")
[docs] def transmit(self):
raise Error("GroundStation.transmit interface not implemented")
[docs] def track(self):
raise Error("GroundStation.track interface not implemented")
[docs] def do_action(self, desc, *args, **kwargs):
raise Error("GroundStation.do_action interface")
##############################
#
# Interface methos (may be overloaded)
#
##############################
[docs] def power_up(self):
log.debug("Nothing is being powered on by ground station since the power_up interface has not been defined")
[docs] def power_down(self):
log.debug("Nothing is being powered down by ground station since the power_down interface has not been defined")
[docs]class UILogHandler(logging.Handler):
def __init__(self, gsstate):
self._stateinstance = gsstate
super(UILogHandler, self).__init__()
[docs] def emit(self, record):
self._stateinstance.libgs_log = self.format(record)
[docs] def test(self, m):
self._stateinstance.libgs_log = m
# TODO: Review GSState and which part of it to keep in libgs and which to
# move to adfags.
# It also includes a bunch of stuff that isnt needed anymore which would
# be good to get rid of
[docs]class GSState(object):
"""
Class to hold ground station state variables.
The ground station uses this class to store important state variables,
(as its properties). The class does however have the ability to connect
callbacks to those properites in order to, for example, update a UI.
Makes use of python properties to ensure the callbacks.
The state properties are:
======= ======================================= ========================
prop Description type
======= ======================================= ========================
curpos Current antenna position(s) tuple or tuple-of-tuples
cmdpos Commanded antenna position(s) tuple or tuple-of-tuples
satpos Current tracked satellite position(s) tuple
nid Current tracked satellite Norad ID int
pdat Current pass data DataFrame
state Current tracking state str
======= ======================================= ========================
.. todo::
Complete this table
"""
#TODO Complete table
#
# Tracking states
#
IDLE = 'idle'
SLEWING = 'slewing'
WAITING = 'waiting'
TRACKING = 'tracking'
#: The logging handler that is used for adding log messages to ui
uiloghandler = None
def __init__(self, callbacks={}, callback_dt = {}, pollmap={}):
# Initialise all the properties
self._curpos = []
self._cmdpos = []
self._satpos = None
self._nid = None
self._pdat = None
self._state = None
self._schedule = None
self._track_msg = None
self._libgs_log = []
self._last_response = None
self._POLL_INTERVAL = 10.0 #<--- seconds between each poll of polled state variables
#
# Initialise logging to the state variable
#
ch = UILogHandler(self)
ch.setLevel(logging.INFO)
cf = UTCLogFormatterHTML(Defaults.LOG_FORMAT)
ch.setFormatter(cf)
log.addHandler(ch)
logging.getLogger('libgs_ops-log').addHandler(ch) #<-- also show libgs_ops output
# Make available as a property so that it can be
self.uiloghandler = ch
#
# The map of functions to poll to set state attributes
# (in most cases teh ground station will set them directly and
# therefore the poller is not necessary)
# The state can be updated through polling for items that do not set
# it directly. The polling functions are specified as a dict where
# the key is the state attribute to update, and the value is the function
# to call to update it.
self.pollmap = pollmap
# TODO: Replace this with a RegularCallback (or at least by a wait_loop)
def _poller():
while (True):
for k,v in self.pollmap.items():
setattr(self, k, v())
try:
wait_loop(timeout = self._POLL_INTERVAL)
except AbortAllException:
log.debug("gsstate._poller aborted with global abort_all event")
return
self._pthr_poll = threading.Thread(target = _poller)
self._pthr_poll.daemon = True
self._pthr_poll.start()
# The name of the class that is permitted to change properties
# Attempting to change from anywhere else will fail.
# To permit mutation from anywhere, delte this property
# To disable from anywhere, set it to None
self._mutable = 'GroundStation'
self.callbacks = dict()
self._last_called = dict()
self.callback_dt = dict()
for k,v in callbacks.items():
if str(k)[0:7] != 'update_':
raise Exception('Callback keys must have the form update_funcname')
for k,v in callback_dt.items():
if str(k)[0:7] != 'update_':
raise Exception('Callback keys must have the form update_funcname')
self.callbacks[k] = v
def _setter(dtype, muting_class=None):
"""
This method generates a decorator that checks the input type
when setting properties and calls the specified callback function,
also specifying a maximum call frequency
(specified with the callbacks property)
"""
def decorator(func):
def func_wrapper(self, value):
# Check type (also allow None)
if (dtype is not None) and not (value is None or (isinstance(value, dtype))):
raise Exception("Argument to %s must be %s, not %s"%(func.func_name, dtype, type(value)))
# Only allow mutation if called from the allowed class
# (the groundstation)
if muting_class is None and hasattr(self, '_mutable'):
mutable = self._mutable
else:
mutable = muting_class
if mutable is not None:#hasattr(self, '_mutable'):
try:
calling_class = sys._getframe().f_back.f_locals['self'].__class__.__name__
if (calling_class != 'GSState') and (calling_class != mutable):#self._mutable:
raise Exception("Changing properties is not permitted")
except KeyError:
raise Exception("Changing properties is not permitted")
except:
raise
func(self, value)
key = 'update_'+func.func_name
if key in self.callbacks.keys():
if (not (key in self.callback_dt.keys())) or\
(not (key in self._last_called.keys())) or\
(time.time() - self._last_called[key] > self.callback_dt[key]):
self.callbacks[key](value)
self._last_called[key] = time.time()
return func_wrapper
return decorator
[docs] def tracking_info():
"""
Return info about tracking state
"""
pass
[docs] def hardware_info():
pass
[docs] def antenna_info():
pass
[docs] def service_info():
pass
def __str__(self):
"""
Format a basic string to describe the state
.. todo::
Complete with hardware power states and other stuff...
"""
s = "Ground Station state\n"
s += "=======================\n"
s += "\n"
s += "Antenna\n"
s += "-----------------------\n"
s += "Current state: {}\n".format(self.state)
s += "Commanded position(s): {}\n".format(self.cmdpos)
s += "Current position(s): {}\n".format(self.curpos)
s += "\n"
if hasattr(self, 'pdu'):
s += "PDU\n"
s += "-----------------------\n"
s += self.pdu
return(s)
def __repr__(self):
return self.__str__()
@property
def curpos(self):
return self._curpos
@property
def cmdpos(self):
return self._cmdpos
@property
def satpos(self):
return self._satpos
@property
def state(self):
return self._state
@property
def pdat(self):
return self._pdat
@property
def nid(self):
return self._nid
# TODO: Store entire schedule (not just text) in state and do not
# permit two scheduler to be started with same gs
@property
def schedule(self):
return self._schedule
@property
def track_msg(self):
return self._track_msg
@property
def libgs_log(self):
return '\n'.join(self._libgs_log)
@property
def pdu(self):
return self._pdu
@property
def last_response(self):
return self._last_response
@last_response.setter
@_setter(basestring)
def last_response(self, msg):
self._last_response = msg
@pdu.setter
@_setter(basestring)
def pdu(self, value):
self._pdu = value
@libgs_log.setter
@_setter(basestring, 'UILogHandler')
def libgs_log(self, msg):
self._libgs_log += [msg]
if len(self._libgs_log) > Defaults.UI_LOG_LEN:
self._libgs_log = self._libgs_log[-Defaults.UI_LOG_LEN:]
@track_msg.setter
@_setter(basestring)
def track_msg(self, msg):
self._track_msg = msg
@schedule.setter
@_setter(basestring)
def schedule(self, value):
self._schedule = value
@satpos.setter
@_setter(tuple)
def satpos(self, value):
self._satpos = value
@cmdpos.setter
@_setter(list)
def cmdpos(self, value):
self._cmdpos = value
@curpos.setter
@_setter(list)
def curpos(self, value):
self._curpos = value
@state.setter
@_setter(basestring)
def state(self, state):
if state == 'idle':
self._pdat = None
self._nid = None
self._state = state
@pdat.setter
@_setter(DataFrame)
def pdat(self, pdat):
self._pdat = pdat
@nid.setter
@_setter(int)
def nid(self, nid):
self._nid = nid
[docs]class GroundStation(GroundStationBase):
"""
Main class for the ground station
"""
RECORD_SPECTRA = Defaults.RECORD_SPECTRA
RECORD_SPECTRA_MAX_LEN = Defaults.RECORD_SPECTRA_MAX_LEN
RECORD_SPECTRA_DT = Defaults.RECORD_SPECTRA_DT
MAX_TRACK_DT = Defaults.MAX_TRACK_DT
#############################################
#
# Interface methods
#
#############################################
[docs] def set_rangerate(self, range_rate):
def _set_rangerate_and_wait(radio, range_rate):
t0 = time.time()
while (int(radio.range_rate) != int(range_rate)):
radio.range_rate = range_rate
if time.time() > t0 + 1.0:
return False
safe_sleep(0.1)
return True
succeeded = 0
for r in self.radios:
# if not r.controllable("range_rate"):
# log.debugv("range_rate on radio %s is not currently controllable"%(r.name))
# continue
try:
# dont change rate if already correct
if int(r.range_rate) == int(range_rate):
succeeded += 1
continue
except Exception as e:
Error("Error reading range_rate: {} : {}".format(type(e).__name__, e.args))
# Set range rate otherwise
try:
ret = _set_rangerate_and_wait(r, range_rate)
if ret == False:
raise Error("Timeout while trying to set range_rate on radio %s"%(r.name))
succeeded += 1
except Exception, e:
log.debugv("Failed to set range_rate for radio %s. Error: %s"%(r.name, e))
if succeeded == 0:
log.debugv("Could not set range_rate on any radio")
#log.info("Range rate set to %d m/s"%(range_rate))
# TODO: deal with this wait. MAybe reomve? It conflicts wit conf.timeouts
[docs] def transmit(self, msg, wait=Defaults.TX_REPLY_TIMEOUT):
"""
Transmit something to a satellite
"""
if not isinstance(msg, bytearray):
raise Error("Msg to transmit must be of type bytearray")
if self.protocol is None:
raise Error("transmit requested, but no protocol has been assigned.")
self.state.last_response = None
try:
self.protocol.send_bytes(msg, wait=wait)
except Exception, e:
self._log_comms("GS", "GS", "FAILURE: %s"%(e))
raise
logmsg =["%02X"%(x) for x in msg]#map(ord, msg)]
self._log_comms("GS", "Sat", '-'.join(logmsg))
[docs] def do_action(self, desc, *args, **kwargs):
"""
Ask the protocol to do something (to presumably do something with
the satellites) without any restrictions. The args and kwargs
are anything implemented in the protocol.
Note that when using this mode of transmission, logging is a bit
trickier. This function will do the best it can but since it doesnt
know much, that might not be very nice. Try to use the other
function unless you have to do something fancy.
The desc parameter is required in order to put SOMETHING in the log.
"""
self._log_comms("GS", "Protocol", "`%s`<%s, %s>"%(desc, args, kwargs))
if self.protocol is None:
raise Error("do_action requested, but protocol not connected")
self.protocol.do_action(*args, **kwargs)
[docs] def track(self, nid_or_pdata, **kwargs):
"""
Track satellite by Norad ID
Track by moving in steps across the trajectory of the satellite
Args:
nid (int) or schedule (pdata) : Either the Norad ID or the schedule to track.
If Norad ID is provided and a propagator connected, then the
method will compute the pdata itself.
nid_or_pdata (int/pdata) : Either a schedule file (pdata) or a norad ID to track.
max_offp : max offpointing to allow
If x0 is the actual satellite position at time t0:
1. point to x0 + max_offp,
2. wait until x0 - max_offp
3. move to x0 + max_offp again ... etc
Since the antenna requires a finite amount of time to move, if
max_offp is too small it will not have a chance to dwell at all.
min_dwell defaults to 1 second. An exception will be raised if
the antenna needs to move again before the minimum dwell time.
"""
#
# Get pass data and Norad ID depending on what is provided in arguments
#
if isinstance(nid_or_pdata, int):
# compute pdata
if self.propagator is None:
raise Error("Cannot compute pass data; no propagator has been connected")
else:
pdat, psum = self.propagator.compute_pass(nid_or_pdata)
log.info("Computed upcoming pass for NID %d to track: \n "%(nid_or_pdata) + str(psum[['tstamp_str', 'az', 'el']]).replace('\n', '\n '))
else:
pdat = nid_or_pdata
if hasattr(pdat, 'nid'):
nid = pdat.nid
else:
nid = None
nid_lbl = nid
if nid <= 0:
nid = None
try:
rotators = kwargs['rotators']
except:
rotators = self.rotators
# Check that the rotators are connected
if len(rotators) == 0:
log.error('No rotator connected to ground station - "tracking" with a dummy')
rotators = [DummyRotator()]
try:
compute_ant_points = kwargs['compute_ant_points']
except:
compute_ant_points = True
# TODO: Remove. The current rotator class will never move antennas beyond its limits anyway, so it is not necessary to do so here
# try:
# min_el = kwargs['min_el']
# except:
# min_el = rotators[0].MIN_EL
# for r in rotators[1:]:
# if r.MIN_EL > min_el:
# min_el = r.MIN_EL
#
# Check that tracking is not already in progress
#
if self.state.state != self.state.IDLE:
raise Error("Ground station is already tracking, abort current track first")
#
# Reset stop flag
#
self._stop_track= False
pass_stats = {}
#
# Pass ID, for tracking communications
#
self.state.pass_id = ("%05d"%(nid_lbl),
pd.to_datetime(str(pdat.tstamp_str.iloc[-1])).strftime('%Y%m%d%H%M%S'))
#
# Increase monitoring rate for duration of pass
#
self._set_monitor_pass(True)
# TODO: Remove. The current rotator never allows moving anenna beyond its limtis anyway so not necessary
# to do that check here.
#
# Crop pass data to only include data above the visibility horizon
#
# pdat = pdat[pdat.el >= min_el]
#
# if pdat.empty:
# raise Error("Pass does not ever become visible. Cannot track")
# Save tracking data to state
self.state.pdat = pdat
s0 = ''
if nid is not None:
#
# If we know which satellite we are tracking then
# update state with its current position
#
self.state.nid = nid
#
# Get current satellite position
#
if self.propagator is not None:
self.state.satpos = self.propagator.get_angles(nid)
s0 = 'Tracking %05d'%(nid)
log.info(s0)
#
# A small helper function to get current UTC time in pyephem format
#
now = lambda : ephem.Date(datetime.utcnow())
def set_rangerate(rate):
try:
self.set_rangerate(rate)
except Exception as e:
log.debug("Error in set_rangerate: {0} : {1!r}".format(type(e).__name__, e.args))
#
# Calculate antenna pointings to use during track
#
if compute_ant_points:
# require a 1 second time resolution and allow 10% margin, or otherwise interpolate
if max(np.diff(pdat.index) * (24 * 60 * 60)) > self.MAX_TRACK_DT*1.1:
log.debug("Track does not have fine enough resolution, interpolating to dt={}".format(self.MAX_TRACK_DT))
t_i = np.arange(pdat.index[0], pdat.index[-1], 1.0 / (24 * 60 * 60))
az_i = np.interp(t_i, pdat.index, pdat.az)
el_i = np.interp(t_i, pdat.index, pdat.el)
rr_i = np.interp(t_i, pdat.index, pdat.range_rate)
pdat = DataFrame(index=t_i, data=dict(az=az_i, el=el_i, range_rate=rr_i))
antangs = [r.azel_to_antenna_angles(pdat) for r in rotators]
else:
# This option tracks schedule directly. Should really never be
# done unless you specify a very sparse schedule manually
antangs = [pdat for r in rotators]
# find next pointing (may not be the first if track has started)
try:
first_point = [list(aa.index > now()).index(True) for aa in antangs]
except ValueError, e:
raise Error("Pass is entirely in the past. Cannot track (%s)"%(e))
pass_stats['start_t'] = now()
#
# Slew to start of track
#
self.state.state = self.state.SLEWING
s = ('Slewing to start of track')
log.info(s)
# TODO: incorporate messages in state? not sure
self.state.track_msg = s0 + '\n' + s
for k,aa in enumerate(antangs):
rotators[k].set_azel(aa.iloc[first_point[k]].az, aa.iloc[first_point[k]].el, block=False)
#
# 2) Wait for satellite to come into view
#
self.state.state = self.state.WAITING
s="Waiting for satellite to become visible"
log.info(s)
self.state.track_msg = s0 + '\n' + s
while (now() < pdat.index[0]) and not self._stop_track:
if nid is not None and self.propagator is not None:
self.state.satpos = self.propagator.get_angles(nid)
s = ('Satellite is currently not visible (az=%5.2f, el=%5.2f)'%(self.state.satpos[0], self.state.satpos[1]))
self.state.track_msg = s0 + '\n' + s
time.sleep(1.0)
#
# Start recording radio
#
if self.RECORD_SPECTRA:
radio_recordings = [r.record_spectrum(dt=self.RECORD_SPECTRA_DT, N=self.RECORD_SPECTRA_MAX_LEN, fdec=5, add_zeroes=True) for r in self.radios]
else:
radio_recordings = []
#
# 3) Track satellite
#
self.state.state = self.state.TRACKING
cur_point = first_point
num_points = [len(aa) for aa in antangs]
pass_stats['start_track_t'] = now()
pass_stats['max_el'] = -1
log.info("Now tracking")
while (now() <= pdat.index[-1]) and not self._stop_track:
for k,aa in enumerate(antangs):
if cur_point[k] < (num_points[k] -1) and (now() > aa.index[cur_point[k] + 1]):
cur_point[k] += 1
new_el = aa.iloc[cur_point[k]].el
rotators[k].set_azel(aa.iloc[cur_point[k]].az, new_el, block=False)
new_el_adj = 180 - new_el if new_el > 90 else new_el
if pass_stats['max_el'] < new_el_adj:
pass_stats['max_el'] = new_el_adj
# It's not ideal to do this continuously, but since the radio might
# reboot at any time it is important to keep the range rate updated
# Note that: set_rangerate does not actually set the variable if it hasnt changed, however
# It *does* issue an xmlrpc call to check if it has been set.
range_rate = pdat.range_rate[pdat.index > now()]
if len(range_rate) == 0:
range_rate = pdat.range_rate.iloc[-1]
else:
range_rate = range_rate.iloc[0]
set_rangerate(range_rate)
s = "Tracking"
if nid is not None and self.propagator is not None:
s = ('Tracking satellite at (az=%5.2f, el=%5.2f)'%(self.state.satpos[0],self.state.satpos[1]))
self.state.satpos = self.propagator.get_angles(nid)
else:
cur_az = pdat.az[pdat.index > now()]
cur_el = pdat.el[pdat.index > now()]
if len(cur_az) > 0 and len(cur_el) > 0:
self.state.satpos = (cur_az.iloc[0], cur_el.iloc[0])
s = ('Tracking (az=%5.2f, el=%5.2f)' % (self.state.satpos[0], self.state.satpos[1]))
self.state.track_msg = s0 + '\n' + s
time.sleep(0.1)
# Mark the time of the end of track
pass_stats['end_track_t'] = now()
# Move the yellow dot (satellite position) out of the way so we dont see it on plot
self.state.satpos = (0, -90)
log.debug("Hiding satellite position. Current marked position: {}".format(self.state.satpos))
#
# Stop recording
#
for radrec in radio_recordings:
radrec.abort()
#
# 4) Stow antenna
#
self.state.state = self.state.SLEWING
s = "Stowing antenna"
log.info(s)
self.state.track_msg = s
self.stow(block=True) #TODO: Should it be true or false? I.e. do we want it to wait until antennas are stowed or not
pass_stats['stowed_t'] = now()
try:
s = "\n Track stats pass %s / %s:"%self.state.pass_id
self._passlog.put(module=__name__, pass_id = self.state.pass_id[1], key='norad_id', value=self.state.pass_id[0])
for k,v in pass_stats.items():
v = conv_time(v, to='iso', ignore_ambig_types=True)
self._passlog.put(module=__name__, pass_id = self.state.pass_id[1], key=k, value=v)
s += "\n %15s = %s"%(k,v)
log.info(s)
if self.RECORD_SPECTRA:
image_data = _mpl_make_waterfall_jpg([r.name for r in self.radios], radio_recordings)
self._passlog.put(module=__name__, pass_id = self.state.pass_id[1], key="waterfall_jpeg", value=image_data)
except Exception as e:
log.exception("Exception saving pass stats to db")
#
# 5) Return to idle
#
s = "Antenna/Rotator state is idle"
log.info(s)
self.state.track_msg = s
self.state.state = self.state.IDLE
self.state.pass_id = None
#
# Reduce monitoring save interval again
#
self._set_monitor_pass(False)
#########################################################
#
# Non-interface private methods
#
########################################################
def __init__(
self,
name,
propagator = None,
protocols = [],
radios = [],
rotators = [],
commslog = CommsLog(),
passlog = PassDb(),
monlog = None,
rpcserver = None
):
# initialise state
self.state = GSState()
self.state.name = name
self.state.state = self.state.IDLE
self.state.pass_id = None
# initialise connected hardware
self.propagator = propagator
self.rotators = rotators
self.radios = radios
# initialise protocols
self.protocols = protocols
# connect handlers
for p in self.protocols:
# Set the handler for received messages
p.set_handler(self.receive)
# assign first protocol (note this also connects the receive handler)
self.protocol = protocols[0] if len(protocols) > 0 else None
# initialise RPC interface
self.rpcserver = rpcserver
#
# Communications database
#
self._commslog = commslog
#
# Pass database
#
self._passlog = passlog
#
# Monitoring database
#
self._monlog = monlog
self._set_monitor_pass(False)
self._monitor_logger_last_t = {}
# Power up everything
self.power_up()
#
# Initialise flags
#
self._stop_track = False
#
# Start regular polling of antenna positions.
#
def update_pos():
self.state.curpos = [r.get_azel() for r in self.rotators]
self.state.cmdpos = [(r.cmd_az, r.cmd_el) for r in self.rotators]
if rotators is not None:
self._pos_poller = RegularCallback(update_pos, Defaults.GS_ROTATOR_POLLING_INTERVAL)
self._pos_poller.start()
def __repr__(self):
return self.__str__()
def __str__(self):
# TODO: Clean up - keep state stuff in state class
s = "Ground Station {}\n".format(self.state.name)
s += " Propagator : " + ("not connected" if self.propagator is None else self.propagator.__str__()) + '\n'
s += " Protocol : " + ("not connected" if self.protocol is None else self.protocol.__str__()) + '\n'
s += " Protocols avail: {}\n".format([p.name for p in self.protocols])
if len(self.radios) == 0:
s += " Radios : not connected\n"
else:
for k,r in enumerate(self.radios):
s += " Radio[{:2}] : {}\n".format(k, r.__str__())
if len(self.rotators) == 0:
s += " Rotators : not connected\n"
else:
for k,r in enumerate(self.rotators):
s += " Rotator[{:2}] : {}\n".format(k, r.__str__())
return s
def _monitor_logger_cb(self, name, tstamp, exc, res):
"""
Monitor callback that saves monitor data to database
"""
# Ensure a minimum interval of self._monitor_logger_dt happens before logging
if name in self._monitor_logger_last_t.keys() and time.time() < self._monitor_logger_dt + self._monitor_logger_last_t[name]:
return
self._monitor_logger_last_t[name] = time.time()
if self._monlog is not None:
if exc is not None:
ret = 'ERROR {}'.format(exc)
alert = res.alertstr
else:
ret = res.val
alert = res.alertstr
if not hasattr(self.state, 'pass_id') or self.state.pass_id is None:
pass_id = 'no pass'
else:
pass_id = self.state.pass_id[1]
# TODO: Decide if we want alert string or alert code in database
self._monlog.put(pass_id, name, ret, alert=alert)
def _set_monitor_pass(self, passing):
"""
Just a convenience function to update the interval at which _save_monitor_data is invoked for monitor data
"""
# if not hasattr(self, '_monitor_logger'):
# log.debug("Cannot set/unset monitor pass as no monitor connected")
# return
# if self._monitor_logger is not None:
# if passing:
# self._monitor_logger._dt = self.MONITOR_SAVE_DT_TRACK
# log.info("Decreased interval at which monitoring data is saved to {}".format(self._monitor_logger._dt))
# else:
# self._monitor_logger._dt = self.MONITOR_SAVE_DT_NOTRACK
# log.info("Increased interval at which monitoring data is saved to {}".format(self._monitor_logger._dt))
if passing:
self._monitor_logger_dt = self.MONITOR_SAVE_DT_TRACK
else:
self._monitor_logger_dt = self.MONITOR_SAVE_DT_NOTRACK
def _log_comms(self, orig, dest , msg):
"""
Add communication to log
.. todo::
Streamline how this is done. Should be saved to a sql database
"""
if not hasattr(self.state, 'pass_id') or self.state.pass_id is None:
pass_id = (0,'unknown')
else:
pass_id = self.state.pass_id
# save message
self._commslog.put(pass_id[0], pass_id[1], orig, dest, msg)
################################################
#
# Non-interface public methods
#
###############################################
#
# Log entry for failed communication
#
FAILED_COMMUNICATION = 'Communication failed'
[docs] def set_schedule_msg(self, msg):
self.state.schedule = msg
# def register_monitor(self, *args, **kwargs):
# """
# Just a wrapper for Monitor.register_monitor
# """
# self._monitor.register_monitor(*args, **kwargs)
[docs] def receive(self, msg):
msg = bytearray(msg)
if msg[:7] != 'FAILURE':
logmsg =["%02X"%(x) for x in msg]# map(ord, msg)]
logmsg = '-'.join(logmsg)
self._log_comms("Sat", "GS", logmsg)
else:
#
# TODO: Record more details on comms failures by allowing
# FAILURE: <some message> to be sent from the protocol
#
logmsg = self.FAILED_COMMUNICATION
self._log_comms( "Protocol", "GS", logmsg)
self.state.last_response = logmsg
if __name__=="__main__":
pass