changeset 31:c6c86dcb54ba

Add code to automate a sitesurvey (to some degree).
author Daniel O'Connor <darius@dons.net.au>
date Wed, 21 Sep 2011 15:00:24 +0930
parents 9ce709b7da4b
children 660a2997e720
files sitesurvey.ini sitesurvey.py specan.py
diffstat 3 files changed, 432 insertions(+), 0 deletions(-) [+]
line wrap: on
line diff
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/sitesurvey.ini	Wed Sep 21 15:00:24 2011 +0930
@@ -0,0 +1,41 @@
+[general]
+sequence = foo bar
+#url = rsib://analyzer
+#type = RSSPA
+url = vxi://129.241.72.183
+type = AnSPA
+fname = Trondheim_%%(timestamp_dec)s_%%(tag)s.dat
+ampcal = GS_preamp_20110919.csv
+
+[foo]
+fstart = 20e6
+fstop = 30e6
+atten = 0
+reflev = -30
+resbw = 10e3
+vidbw = 30e3
+sweept = 1
+recurrence = 180
+
+[bar]
+fstart = 25e6
+fstop = 37e6
+atten = 0
+reflev = -30
+resbw = 10e3
+vidbw = 30e3
+sweept = 1
+recurrence = 60
+
+[rs]
+fstart = 1e6
+fstop = 10e6
+atten = 0
+reflev = -30
+resbw = 10e3
+vidbw = 30e3
+sweept = 1
+sweeppts = 8001
+sweepcnt = 4
+detector = RMS
+recurrence = 600
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/sitesurvey.py	Wed Sep 21 15:00:24 2011 +0930
@@ -0,0 +1,265 @@
+#!/usr/bin/env python
+
+# Copyright (c) 2011
+#      Daniel O'Connor <darius@dons.net.au>.  All rights reserved.
+#
+# Redistribution and use in source and binary forms, with or without
+# modification, are permitted provided that the following conditions
+# are met:
+# 1. Redistributions of source code must retain the above copyright
+#    notice, this list of conditions and the following disclaimer.
+# 2. Redistributions in binary form must reproduce the above copyright
+#    notice, this list of conditions and the following disclaimer in the
+#    documentation and/or other materials provided with the distribution.
+#
+# THIS SOFTWARE IS PROVIDED BY AUTHOR AND CONTRIBUTORS ``AS IS'' AND
+# ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+# ARE DISCLAIMED.  IN NO EVENT SHALL AUTHOR OR CONTRIBUTORS BE LIABLE
+# FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS
+# OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION)
+# HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
+# LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY
+# OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF
+# SUCH DAMAGE.
+#
+
+import calendar
+import ConfigParser
+import datetime
+import exceptions
+import numpy
+import os
+import scpi
+import specan
+import sys
+import time
+
+defaults = {}
+confname = "sitesurvey.ini"
+confpaths = [ ".", os.path.dirname(os.path.realpath(sys.argv[0]))]
+
+class Experiment(object):
+    def __init__(self, conf, name):
+        if not conf.has_section(name):
+            raise exceptions.KeyError("No section for experiment " + name)
+
+        self.name = name
+        self.recurrence = None
+        self.opts = {}
+        for k, v in conf.items(name):
+            if k == "recurrence":
+                # In seconds
+                self.recurrence = int(v)
+                continue
+            
+            try:
+                self.opts[k] = int(v)
+            except exceptions.ValueError, e:
+                try:
+                    self.opts[k] = float(v)
+                except exceptions.ValueError, e:
+                    self.opts[k] = v
+
+        if self.recurrence == None:
+            raise exceptions.KeyError("Mandatory parameter 'recurrence' is missing")
+        self.recurrence = datetime.timedelta(seconds = self.recurrence)
+
+        self.last_run = None
+
+    def __repr__(self):
+        return "<" + self.name + ">"
+
+class CalFile(object):
+    def __init__(self, fname):
+        f = file(fname)
+        if f.readline().strip() != "Frequencies":
+            raise exceptions.SyntaxError("Format of cal file incorrect (frequencies header missing)")
+        freqs = numpy.fromstring(f.readline().strip(), sep = ',')
+        if f.readline().strip() != "Gain":
+            raise exceptions.SyntaxError("Format of cal file incorrect (gains header missing)")
+        gains = numpy.fromstring(f.readline().strip(), sep = ',')
+        if len(gains) != len(freqs):
+            raise exceptions.SyntaxError("Format of cal file incorrect (length of gain and freqs aren't equal)")
+
+        self.calfreqs = freqs
+        self.calgains = gains
+
+    def interp(self, freqs):
+        '''Interoplate the calibration over freqs and return an array'''
+        deltas = numpy.zeros(freqs.shape)
+
+        for i in range(len(freqs)):
+            if freqs[i] < self.calfreqs[0] or freqs[i] > self.calfreqs[-1]:
+                raise exceptions.SyntaxError("Frequency %.1f is out of range of calibration %f - %f" % (f, calfreqs[0], calfreqs[-1]))
+
+            # Find idx such that calfreqs[idx - 1] < freqs[i] <= calfreqs[idx]
+            idx = self.calfreqs.searchsorted(freqs[i])
+            sf = (freqs[i] - self.calfreqs[idx - 1]) / (self.calfreqs[idx] - self.calfreqs[idx - 1])
+            delta = ((self.calgains[idx] - self.calgains[idx - 1]) * sf) + self.calgains[idx]
+            deltas[i] = delta
+        return deltas
+
+
+def getexpt(sequence):
+    '''Given a sequence return the experiment which should be run next and how long until it should start'''
+
+    now = datetime.datetime.utcnow()
+    #print "now is " + str(now)
+    soonestdly = None
+    soonestexp = None
+    
+    for e in sequence:
+        #print "Looking at " + str(e)
+        # If an experiment has ever run do it now
+        if e.last_run == None:
+            return e, datetime.timedelta(0)
+
+        # Time until this experiment should be run
+        nextrun = e.last_run + e.recurrence
+        dly = nextrun - now
+        #print "Last run was at %s, nextrun at %s, rec = %s, dly = %s / %f" % (str(e.last_run), str(nextrun), str(e.recurrence), str(dly), dly.total_seconds())
+        # Haven't looked at an experiment yet or this one is sooner
+        if soonestdly == None or dly < soonestdly:
+            #print "sooner"
+            soonestdly = dly
+            soonestexp = e
+
+    if soonestdly < datetime.timedelta(0):
+        #print "Capping " + e.name + " to run now"
+        soonestdly = datetime.timedelta(0)
+
+    #print "Returning " + str(soonestexp)
+    return soonestexp, soonestdly
+
+def getsweep(inst, conf):
+    print " Sending configuration"
+
+    for k in conf:
+        #time.sleep(0.3)
+        inst.setconf(k, conf[k])
+
+    # Otherwise the R&S doens't respond..
+    #time.sleep(0.3)
+    rconf = inst.dumpconf()
+    fstart = rconf['fstart']
+    fstop = rconf['fstop']
+    print " Configuration is " + str(rconf)
+    
+    print " Fetching trace"
+    yaxis = inst.gettrace()
+    xaxis = numpy.arange(fstart, fstop, (fstop - fstart) / yaxis.shape[0])
+
+    return xaxis, yaxis, rconf
+
+def savesweep(fname, exp, x, y):
+    f = open(fname, 'wb')
+    for k in exp:
+        f.write("%s %s\n" % (k.upper(), str(exp[k])))
+    f.write("XDATA ")
+    numpy.savetxt(f, [x], delimiter = ', ', fmt = '%.3f') # Produces a trailing \n
+    f.write("YDATA ")
+    numpy.savetxt(f, [y], delimiter = ', ', fmt = '%.3f')
+    del f
+
+def total_seconds(td):
+    return (td.microseconds + (td.seconds + td.days * 24.0 * 3600.0) * 10.0**6) / 10.0**6
+
+if __name__ == '__main__':
+    # Read in config file(s)
+    conf = ConfigParser.SafeConfigParser(defaults)
+    r = conf.read(map(lambda a: os.path.join(a, confname), confpaths))
+    if len(r) == 0:
+        print "Unable to find any configuration file(s)"
+        sys.exit(1)
+
+    if not conf.has_section('general'):
+        print "Configuration file doesn't have a 'general' section"
+        sys.exit(1)
+
+    if not conf.has_option('general', 'url'):
+        print "Configuration file doesn't have a 'url' option in the 'general' section"
+        sys.exit(1)
+
+    if not conf.has_option('general', 'type'):
+        print "Configuration file doesn't have a 'type' option in the 'general' section"
+        sys.exit(1)
+
+    if not conf.has_option('general', 'sequence'):
+        print "Configuration file doesn't have a 'sequence' option in the 'general' section"
+        sys.exit(1)
+
+    if not conf.has_option('general', 'fname'):
+        print "Configuration file doesn't have a 'fname' option in the 'general' section"
+        sys.exit(1)
+
+    if conf.has_option('general', 'ampcal'):
+        ampcal = CalFile(conf.get('general', 'ampcal'))
+    else:
+        ampcal = None
+
+    if conf.has_option('general', 'antcal'):
+        antcal = CalFile(conf.get('general', 'antcal'))
+    else:
+        antcal = None
+
+
+    sequence = []
+    seqnames = conf.get('general', 'sequence').split()
+    for e in seqnames:
+        sequence.append(Experiment(conf, e))
+                        
+    url = conf.get('general', 'url')
+    insttype = conf.get('general', 'type')
+    fnamefmt = conf.get('general', 'fname')
+    
+    # Connect to the instrument
+    print "Connecting to " + url
+    con = scpi.instURL(url)
+    con.write("*IDN?")
+    idn = con.read()
+    print "Instrument is a " + idn
+
+    # Get class for this instrument & instantiate it
+    inst = specan.getInst(insttype)(con)
+
+    while True:
+        # Find the next experiment to run
+        exp, dly = getexpt(sequence)
+
+        # Sleep if necessary
+        dly = total_seconds(dly)
+        if dly > 1:
+            print "Sleeping for %.1f seconds" % (dly)
+            time.sleep(dly)
+
+        # Run it
+        print "--> Running experiment " + str(exp)
+        freqs, power, opts = getsweep(inst, exp.opts)
+
+        # Adjust power based on amplifier and antenna calibration
+        if ampcal != None:
+            adj = ampcal.interp(freqs)
+            power = power - adj
+
+        if antcal != None:
+            adj = antcal.interp(freqs)
+            power = power - adj
+
+        # Update last run time
+        exp.last_run = datetime.datetime.utcnow()
+
+        # Add some informative params
+        tsepoch = calendar.timegm(exp.last_run.utctimetuple())
+        
+        extras = { 'timestamp' : exp.last_run,
+                   'timestamp_hex' : '%08x' % (tsepoch),
+                   'timestamp_dec' : '%d' % (tsepoch),
+                   'tag' : exp.name,
+                   }
+        opts = dict(opts.items() + extras.items())
+        fname = fnamefmt % opts
+
+        # Save data
+        savesweep(fname, opts, freqs, power)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/specan.py	Wed Sep 21 15:00:24 2011 +0930
@@ -0,0 +1,126 @@
+import exceptions
+import numpy
+import scpi
+
+class Traceinst(object):
+    '''Generic class for a trace based instrument'''
+    attrs = {}
+    tracetypename = None
+    tracedtype = None
+    tracequery = None
+    
+    def __init__(self, con):
+        self.con = con
+        # Set trace format
+        self.con.write("FORM " + self.tracetypename)
+
+    def setconf(self, name, value):
+        if name not in self.attrs:
+            raise exceptions.KeyError(name + " not supported")
+        # Check value is correct type
+        tmp = self.attrs[name][1](value)
+        # Run validation function (if necessary)
+        if self.attrs[name][2] != None:
+            self.attrs[name][2](value)
+        #print "Setting %s to %s" % (self.attrs[name][0], str(value))
+        self.con.write("%s %s" % (self.attrs[name][0], str(value)))
+                       
+    def getconf(self, name):
+        if name not in self.attrs:
+            raise exceptions.KeyError(name + " not supported")
+        self.con.write("%s?" % (self.attrs[name][0]))
+        r = self.con.read()
+        return self.attrs[name][1](r)
+
+    def write(self, *args):
+        return self.con.write(*args)
+
+    def read(self, *args):
+        return self.con.read(*args)
+
+    def gettrace(self, timeout = 10):
+        # Trigger the sweep
+        self.con.write("INIT;*WAI")
+
+        # Wait for it to be done
+        if False:
+            self.con.write("*OPC?")
+            opc = scpi.getdata(self.con.read(timeout), int)
+            if opc != 1:
+                return None
+        else:
+            while True:
+                self.con.write(':STATus:OPERation?') 
+                i = scpi.getdata(self.con.read(timeout), int)
+                if i & 256:
+                    break
+
+           
+        # Grab trace data
+        self.con.write(self.tracequery)
+        dat = self.con.read(10)
+
+        # Parse into array
+        ary = scpi.bindecode(dat, dtype = self.tracedtype)
+        return ary
+
+    def dumpconf(self):
+        rtn = {}
+        for k in self.attrs:
+            self.con.write(self.attrs[k][0] + '?')
+            res = self.con.read()
+            #print "Getting " + k + " / " + self.attrs[k][0] + " = " + res
+            rtn[k] = self.attrs[k][1](res)
+        return rtn
+    
+class RSSPA(Traceinst):
+    '''Rhode & Schwartz Spectrum Analyzer'''
+
+    attrs = { 'fstart' : ['FREQ:START', float, None], # Page 561
+              'fstop' : ['FREQ:STOP', float, None],
+              'atten' : ['INP:ATT', float, None], # Page 518
+              'resbw' : ['SENSE:BANDWIDTH:RESOLUTION', float, None], # Page 539
+              'vidbw' : ['SENSE:BANDWIDTH:VIDEO', float, None], # Page 541
+              'sweept' : ['SENSE:SWEEP:TIME', float, None], # Page 599
+              #'sweeppts' : ['SWEEP:POINTS', int, RSSPA.sweepptscheck],
+              'sweeppts' : ['SWEEP:POINTS', int, None], # Page 599
+              'sweepcnt' : ['SENSE1:AVERAGE:COUNT', int, None], # Page 595
+              'reflev' : ['DISPLAY:WINDOW1:TRACE1:Y:SCALE:RLEVEL', float, None], # Page 506
+              'detector' : ['SENSE1:DETECTOR1:FUNCTION', str, None] , # Page 552
+              }
+
+    tracetypename = 'REAL,32'
+    tracedtype = numpy.float32
+    tracequery = 'TRAC1? TRACE1'
+    swptslist = [125, 251, 501, 1001, 2001, 4001, 8001]
+
+#    def sweepptscheck(npts):
+#        if x not in RSSPA.swptslist:
+#            raise exceptions.ValueError("Sweep value not supported, must be one of " + str(RSSPA.swptslist))
+        
+        
+class AnSPA(Traceinst):
+    '''Anritsu Spectrum Analyzer'''
+    attrs = { 'fstart' : ['FREQ:START', float, None],
+             'fstop' : ['FREQ:STOP', float, None],
+             'atten' : ['SENSE:POWER:ATTENUATION', float, None],
+             'sweept' : ['SENSE:SWEEP:TIME', float, None],
+             'sweepcnt' : [':SENSe:AVERage:COUNt', int, None],
+             'tracemode' : [':SENSe:AVERage:TYPE', str, None],
+             'resbw' : ['SENSE:BANDWIDTH:RESOLUTION', float, None],
+             'vidbw' : ['SENSE:BANDWIDTH:VIDEO', float, None],
+             'reflev' : [':DISPLAY:WIND:TRACE:Y:SCALE:RLEVEL', float, None],
+             'detector' : [':SENSe:DETector:FUNCtion', str, None],
+    }
+
+    tracetypename = 'REAL,32'
+    tracedtype = numpy.float32
+    tracequery = 'TRACE:DATA?'
+
+def getInst(inst):
+    if inst == "RSSPA":
+        return RSSPA
+    elif inst == "AnSPA":
+        return AnSPA
+    else:
+        raise exceptions.NotImplementedError("unknown instrument type " + inst)