Parameterized Signal Generation Feature

This feature allows the user to generate a narrowband interference signal at
a specified frequency. This signal will turn on and off at a specified period
and duty cycle.

Change-Id: Idd4fa0fe60af51f2ce229e76bd22759b48c4f91e
diff --git a/options.py b/options.py
new file mode 100644
index 0000000..7fb5bcf
--- /dev/null
+++ b/options.py
@@ -0,0 +1,274 @@
+# Copyright 2010-2012 Avery Pennarun and options.py contributors.
+# All rights reserved.
+#
+# (This license applies to this file but not necessarily the other files in
+# this package.)
+#
+# 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 AVERY PENNARUN ``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 <COPYRIGHT HOLDER> 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.
+#
+"""Command-line options parser.
+With the help of an options spec string, easily parse command-line options.
+
+An options spec is made up of two parts, separated by a line with two dashes.
+The first part is the synopsis of the command and the second one specifies
+options, one per line.
+
+Each non-empty line in the synopsis gives a set of options that can be used
+together.
+
+Option flags must be at the begining of the line and multiple flags are
+separated by commas. Usually, options have a short, one character flag, and a
+longer one, but the short one can be omitted.
+
+Long option flags are used as the option's key for the OptDict produced when
+parsing options.
+
+When the flag definition is ended with an equal sign, the option takes one
+string as an argument. Otherwise, the option does not take an argument and
+corresponds to a boolean flag that is true when the option is given on the
+command line.
+
+The option's description is found at the right of its flags definition, after
+one or more spaces. The description ends at the end of the line. If the
+description contains text enclosed in square brackets, the enclosed text will
+be used as the option's default value.
+
+Options can be put in different groups. Options in the same group must be on
+consecutive lines. Groups are formed by inserting a line that begins with a
+space. The text on that line will be output after an empty line.
+"""
+import sys, os, textwrap, getopt, re, struct
+
+
+def _invert(v, invert):
+    if invert:
+        return not v
+    return v
+
+
+def _remove_negative_kv(k, v):
+    if k.startswith('no-') or k.startswith('no_'):
+        return k[3:], not v
+    return k,v
+
+
+class OptDict(object):
+    """Dictionary that exposes keys as attributes.
+
+    Keys can be set or accessed with a "no-" or "no_" prefix to negate the
+    value.
+    """
+    def __init__(self, aliases):
+        self._opts = {}
+        self._aliases = aliases
+
+    def _unalias(self, k):
+        k, reinvert = _remove_negative_kv(k, False)
+        k, invert = self._aliases[k]
+        return k, invert ^ reinvert
+
+    def __setitem__(self, k, v):
+        k, invert = self._unalias(k)
+        self._opts[k] = _invert(v, invert)
+
+    def __getitem__(self, k):
+        k, invert = self._unalias(k)
+        return _invert(self._opts[k], invert)
+
+    def __getattr__(self, k):
+        return self[k]
+
+
+def _default_onabort(msg):
+    sys.exit(97)
+
+
+def _intify(v):
+    try:
+        vv = int(v or '')
+        if str(vv) == v:
+            return vv
+    except ValueError:
+        pass
+    return v
+
+
+def _atoi(v):
+    try:
+        return int(v or 0)
+    except ValueError:
+        return 0
+
+
+def _tty_width():
+    s = struct.pack("HHHH", 0, 0, 0, 0)
+    try:
+        import fcntl, termios
+        s = fcntl.ioctl(sys.stderr.fileno(), termios.TIOCGWINSZ, s)
+    except (IOError, ImportError):
+        return _atoi(os.environ.get('WIDTH')) or 70
+    (ysize,xsize,ypix,xpix) = struct.unpack('HHHH', s)
+    return xsize or 70
+
+
+class Options:
+    """Option parser.
+    When constructed, a string called an option spec must be given. It
+    specifies the synopsis and option flags and their description.  For more
+    information about option specs, see the docstring at the top of this file.
+
+    Two optional arguments specify an alternative parsing function and an
+    alternative behaviour on abort (after having output the usage string).
+
+    By default, the parser function is getopt.gnu_getopt, and the abort
+    behaviour is to exit the program.
+    """
+    def __init__(self, optspec, optfunc=getopt.gnu_getopt,
+                 onabort=_default_onabort):
+        self.optspec = optspec
+        self._onabort = onabort
+        self.optfunc = optfunc
+        self._aliases = {}
+        self._shortopts = 'h?'
+        self._longopts = ['help', 'usage']
+        self._hasparms = {}
+        self._defaults = {}
+        self._usagestr = self._gen_usage()  # this also parses the optspec
+
+    def _gen_usage(self):
+        out = []
+        lines = self.optspec.strip().split('\n')
+        lines.reverse()
+        first_syn = True
+        while lines:
+            l = lines.pop()
+            if l == '--': break
+            out.append('%s: %s\n' % (first_syn and 'usage' or '   or', l))
+            first_syn = False
+        out.append('\n')
+        last_was_option = False
+        while lines:
+            l = lines.pop()
+            if l.startswith(' '):
+                out.append('%s%s\n' % (last_was_option and '\n' or '',
+                                       l.lstrip()))
+                last_was_option = False
+            elif l:
+                (flags,extra) = (l + ' ').split(' ', 1)
+                extra = extra.strip()
+                if flags.endswith('='):
+                    flags = flags[:-1]
+                    has_parm = 1
+                else:
+                    has_parm = 0
+                g = re.search(r'\[([^\]]*)\]$', extra)
+                if g:
+                    defval = _intify(g.group(1))
+                else:
+                    defval = None
+                flagl = flags.split(',')
+                flagl_nice = []
+                flag_main, invert_main = _remove_negative_kv(flagl[0], False)
+                self._defaults[flag_main] = _invert(defval, invert_main)
+                for _f in flagl:
+                    f,invert = _remove_negative_kv(_f, 0)
+                    self._aliases[f] = (flag_main, invert_main ^ invert)
+                    self._hasparms[f] = has_parm
+                    if f == '#':
+                        self._shortopts += '0123456789'
+                        flagl_nice.append('-#')
+                    elif len(f) == 1:
+                        self._shortopts += f + (has_parm and ':' or '')
+                        flagl_nice.append('-' + f)
+                    else:
+                        f_nice = re.sub(r'\W', '_', f)
+                        self._aliases[f_nice] = (flag_main,
+                                                 invert_main ^ invert)
+                        self._longopts.append(f + (has_parm and '=' or ''))
+                        self._longopts.append('no-' + f)
+                        flagl_nice.append('--' + _f)
+                flags_nice = ', '.join(flagl_nice)
+                if has_parm:
+                    flags_nice += ' ...'
+                prefix = '    %-20s  ' % flags_nice
+                argtext = '\n'.join(textwrap.wrap(extra, width=_tty_width(),
+                                                initial_indent=prefix,
+                                                subsequent_indent=' '*28))
+                out.append(argtext + '\n')
+                last_was_option = True
+            else:
+                out.append('\n')
+                last_was_option = False
+        return ''.join(out).rstrip() + '\n'
+
+    def usage(self, msg=""):
+        """Print usage string to stderr and abort."""
+        sys.stderr.write(self._usagestr)
+        if msg:
+            sys.stderr.write(msg)
+        e = self._onabort and self._onabort(msg) or None
+        if e:
+            raise e
+
+    def fatal(self, msg):
+        """Print an error message to stderr and abort with usage string."""
+        msg = '\nerror: %s\n' % msg
+        return self.usage(msg)
+
+    def parse(self, args):
+        """Parse a list of arguments and return (options, flags, extra).
+
+        In the returned tuple, "options" is an OptDict with known options,
+        "flags" is a list of option flags that were used on the command-line,
+        and "extra" is a list of positional arguments.
+        """
+        try:
+            (flags,extra) = self.optfunc(args, self._shortopts, self._longopts)
+        except getopt.GetoptError, e:
+            self.fatal(e)
+
+        opt = OptDict(aliases=self._aliases)
+
+        for k,v in self._defaults.iteritems():
+            opt[k] = v
+
+        for (k,v) in flags:
+            k = k.lstrip('-')
+            if k in ('h', '?', 'help', 'usage'):
+                self.usage()
+            if (self._aliases.get('#') and
+                  k in ('0','1','2','3','4','5','6','7','8','9')):
+                v = int(k)  # guaranteed to be exactly one digit
+                k, invert = self._aliases['#']
+                opt['#'] = v
+            else:
+                k, invert = opt._unalias(k)
+                if not self._hasparms[k]:
+                    assert(v == '')
+                    v = (opt._opts.get(k) or 0) + 1
+                else:
+                    v = _intify(v)
+            opt[k] = _invert(v, invert)
+        return (opt,flags,extra)
diff --git a/signal_generator/README b/signal_generator/README
index 691bbda..a422a75 100644
--- a/signal_generator/README
+++ b/signal_generator/README
@@ -9,17 +9,9 @@
 A general db-gain can also be specified by using the '-g' flag to amplify the
 combined signal.
 
-Options:
-  -h, --help            Show this help message and exit
-  -f FILENAME,FACTOR, --file=FILENAME,FACTOR
-                        Add signal from file (raw 64 bit complex) which can be
-                        generated using the 'bandpass_recorder.py' script. The
-                        raw file can be read using scipy.fromfile.
-  -u FREQUENCY, --frequency=FREQUENCY
-                        Specify the desired frequency in KHz, defaults
-                        to 2412KHz (Wifi Channel 1)
-  -g DB_GAIN, --gain=DB_GAIN
-                        Specify the db-gain amplification, defaults to 50db
+Usage: signal_generator.py [options]
+
+To print usage info use "./signal_generator.py -h" option.
 
 Note: If you are playing from a file and are getting Underruns (U), try moving
 the file to local storage.
diff --git a/signal_generator/options.py b/signal_generator/options.py
new file mode 120000
index 0000000..3508154
--- /dev/null
+++ b/signal_generator/options.py
@@ -0,0 +1 @@
+../options.py
\ No newline at end of file
diff --git a/signal_generator/signal_generator.py b/signal_generator/signal_generator.py
index a693e6a..cd17bcc 100755
--- a/signal_generator/signal_generator.py
+++ b/signal_generator/signal_generator.py
@@ -1,101 +1,279 @@
 #!/usr/bin/python
-from optparse import OptionParser
+"""Signal Generator Tool.
+
+This module uses the Gnuradio framework to take in multiple signals as
+specified on command line and adds them together and broadcasts them using a
+USRP software defined radio.
+
+"""
+from collections import namedtuple
+import ConfigParser
+import math
+import os
+import re
 import sys
+import tempfile
 
 from gnuradio import blocks
 from gnuradio import gr
 from gnuradio import uhd
+import numpy as np
+from scipy.interpolate import interp1d
+
+import options
+
+optspec = """
+signal_generator [options...]
+--
+ Radio Parameters:
+F,frequency=  Specify the frequency of the signal generated in KHz [2412000]
+G,gain=       Specify the Db gain of the radio when it broadcasts. [50.0]
+C,config=     Specify the config file for signal parameters.
+o,filename=      Specify filename to save signal instead of recording.
+ Signal Types:
+f,file=       e.g "-f foo.dat^10.0 to play foo.dat at 10x signal strength
+p,periodic=   e.g "-p 1000%0.5@1000000^10.0" to repeat a sine signal every 1000us with 50% duty centered at 1000000Hz and 10x amplification
+b,bluetooth=  e.g "-b 100" to broadcast a bluetooth-like signal at specified power.
+w,wifi=       e.g "-w 100" to broadcast a wifi-like signal at specified power.
+"""
+SAMPLE_RATE = 30e6
+PROFILE_SIZE = 30000
+
+SignalParameters = namedtuple('ParseSignal', ['prefix', 'amplification',
+                                              'frequency', 'duty'])
+
+
+def parse_signal(parameters):
+  """Parse the command line signal parameters.
+
+  Parameter notation corresponds special symbols with parameter values
+  ^: amplification
+  %: duty cycle
+  @: frequency
+
+  Args:
+    parameters: parameter string to be parsed
+  Returns:
+    ParseSignal object with parsed parameter
+  """
+
+  split_params = re.split(r'([%@^])', parameters.strip('"\''))
+  param_dict = {'%': 0.0, '@': 0.0, '^': 0.0, 'filename': '', 'period': 0.0}
+  param_dict['prefix'] = split_params[0]
+  for i in range(1, len(split_params) - 1, 2):
+    param_dict[split_params[i]] = float(split_params[i + 1])
+
+  return SignalParameters(param_dict['prefix'], param_dict['^'],
+                          param_dict['@'], param_dict['%'])
+
+
+def interpolate(data, factor):
+  x_old = range(0, len(data) * factor, factor)
+  f = interp1d(x_old, data)
+
+  xnew = range(0, len(data) * (factor - 1))
+  ynew = f(xnew)
+  return ynew
 
 
 class SignalGenerator(gr.top_block):
+  """Signal Generation Class.
 
-  def __init__(self):
+  Class to create a GnuRadio top-block which takes multiple signals and
+  broadcasts the sum of the signals. The power and frequency of the signal
+  can be changed. Different types of signals can be added by calling the
+  corresponding method.
+  """
+
+  def __init__(self, filename):
+    """Initialize the top block and create the URSP sink block."""
     super(SignalGenerator, self).__init__('Signal Generator')
 
-    sample_rate = 30e6
-
-    # Initialize the SDR
     stream_args = uhd.stream_args(cpu_format='fc32', otw_format='sc8',
                                   channels=range(1))
-    self.usrp_sink = uhd.usrp_sink(',', stream_args)
+    if filename:
+      self.sink = blocks.file_sink(gr.sizeof_gr_complex, filename, False)
+    else:
+      self.sink = uhd.usrp_sink(',', stream_args)
+      self.sink.set_samp_rate(SAMPLE_RATE)
+      self.sink.set_bandwidth(SAMPLE_RATE)
 
     # Create a list of the file source streams so we can add them as we read
     # arguments.
     self.sources = []
+    self.temp_files = []
 
-    # Adjust parameters
-    self.usrp_sink.set_samp_rate(sample_rate)
-    self.usrp_sink.set_bandwidth(sample_rate)
-
-    # Initialize and connect the signal adder.
     self.add_block = blocks.add_vcc(1)
-    self.connect((self.add_block, 0), (self.usrp_sink, 0))
+    self.connect((self.add_block, 0), (self.sink, 0))
 
-  # Adjust gain value on the USRP.
-  def set_gain(self, gain):
-    self.usrp_sink.set_gain(gain)
-
-  # Adjust center frequency value of the USRP.
-  def set_freq(self, freq):
-    self.usrp_sink.set_center_freq(freq, 0)
-
-  # Add a new signal to be broadcasted.
   def add_file_source(self, filename, factor):
-    # Create and add our file sources paired with their multiplier block to the
-    # sources list.
+    """Create and add a file source to the sources list.
+
+    Args:
+      filename: Path to the file to be broadcasted
+      factor: Amplitude is multiplied by this
+    """
+
     src = blocks.file_source(gr.sizeof_gr_complex, filename, True)
     multiply_block = blocks.multiply_const_vcc((factor,))
     self.connect((src, 0), (multiply_block, 0))
     self.sources.append(multiply_block)
 
+  def add_periodic_signal(self, freq, length, duty, factor):
+    """Add an oscillating parameterized source.
+
+    Args:
+      freq: Frequency in KHz of the peak
+      length: Duration in micro-seconds of one period
+      duty: Proportion of time that the signal is high (0-1)
+      factor: Amplitude is multiplied by this value
+    """
+    up_time = int(length * duty * 30)
+    signal = np.zeros(shape=30 * length, dtype=np.complex64)
+    up_sig = freq * math.pi / 15e6 * np.array(range(up_time))
+    signal.imag[0 : up_time] = np.sin(up_sig)
+    signal.real[0 : up_time] = np.cos(up_sig)
+
+    # Write the signal to the file so it can be read by the file source.
+    f = tempfile.NamedTemporaryFile(mode='w+b', bufsize=0)
+    signal.tofile(f.name)
+    self.temp_files.append(f)
+
+    self.add_file_source(f.name, factor)
+
+  def add_periodic_profile(self, length, duty, factor, profile):
+    """Add an oscillating parameterized source with a frequency profile.
+
+    Args:
+      length: Duration in micro-seconds of one period
+      duty: Proportion of time that the signal is high (0-1)
+      factor: Amplitude is multiplied by this value
+      profile: Vector of the frequency profile
+    """
+    up_time = int(length * duty * 30)
+    signal = np.zeros(shape=30 * length, dtype=np.complex64)
+
+    profile_path = os.path.abspath(os.path.expanduser(profile.strip('\'"')))
+    fourier_down = np.load(profile_path)
+    fourier = interpolate(fourier_down, int(SAMPLE_RATE) / len(fourier_down))
+    interference_sample = np.fft.ifft(fourier, int(SAMPLE_RATE))
+
+    signal[0 : up_time] = interference_sample[0 : up_time]
+    f = tempfile.NamedTemporaryFile(mode='w+b', bufsize=0)
+
+    signal.tofile(f.name)
+    self.temp_files.append(f)
+
+    self.add_file_source(f.name, factor)
+
   def run(self):
-    # Connect all the sources to the adder.
+    """Connect all the sources to the adder and run the system."""
     for i in range(len(self.sources)):
       self.connect((self.sources[i], 0), (self.add_block, i))
     gr.top_block.run(self)
 
-if __name__ == '__main__':
-  # Get command line arguments.
-  parser = OptionParser()
-  parser.add_option('-f', '--file', dest='file', help='Add signal from file '
-                    '(raw 64 bit complex) which can be generated using the '
-                    '\'bandpass_recorder.py\' script. The raw file can be read '
-                    'using scipy.fromfile. If factor is not included, it defaults '
-                    'to 50.0', metavar='FILENAME,FACTOR', action='append')
-  parser.add_option('-u', '--frequency', dest='frequency',
-                    help='Specify the desired frequency in KHz, defaults to'
-                    '2412000KHz (Wifi Channel 1)', metavar='FREQUENCY',
-                    default='2412000')
-  parser.add_option('-g', '--gain', dest='gain',
-                    help='Specify the db-gain amplification, defaults to 50db',
-                    metavar='DB_GAIN', default='50')
-  options, args = parser.parse_args()
 
-  # Initialize the top_block
-  signal_gen_block = SignalGenerator()
-  signal_gen_block.set_freq(float(options.frequency) * 1000.0)
-  signal_gen_block.set_gain(float(options.gain))
+def parse_config(filename):
+  """Parse the config file.
+
+  Args:
+    filename: The filename of the configuration file.
+  Returns:
+    Dictionary of the parameters dividied into signal types and parameter type.
+  """
+  config = ConfigParser.ConfigParser()
+  config.read(filename)
+
+  params = {}
+  for section in config.sections():
+    params[section] = {'duty': config.getfloat(section, 'duty'),
+                       'amplitude': config.getfloat(section, 'amplitude'),
+                       'length': config.getint(section, 'length'),
+                       'profile': config.get(section, 'profile')
+                      }
+  return params
+
+
+def main():
+  o = options.Options(optspec)
+  opt, flags, _ = o.parse(sys.argv[1:])
+
+  signal_gen_block = SignalGenerator(opt.filename)
+  if not opt.filename:
+    signal_gen_block.sink.set_center_freq(float(opt.frequency) * 1000.0)
+    signal_gen_block.sink.set_gain(float(opt.gain))
+
+  if opt.config:
+    signal_params = parse_config(opt.config)
+  else:
+    signal_params = []
 
   # Add the signals to the top_block
-  if options.file is not None:
-    for signal in options.file:
-      args = signal.split(',')
+  # 'signal_count' is used to keep track of the number of signals being
+  # broadcasted for error checking.
+  signal_count = 0
+  print 'Processing Signals...'
+  for flag, value in flags:
+    parameter = parse_signal(value)
+    if flag == '--file' or flag == '-f':
+      # Parse out the parameters from the syntax.
+      factor = parameter.amplification
+      filename = os.path.expanduser(parameter.prefix)
 
-      # Check for number of arguments
-      if len(args) > 1:
-        # Verify that the second argument is a float.
-        try:
-          factor = float(args[1])
-        except:
-          print 'Invalid arguments: {0}'.format(signal)
-          sys.exit(1)
-      else:
-        factor = 50.0
+      signal_gen_block.add_file_source(filename, factor)
+      signal_count += 1
 
-      # Add the signal to the top_block.
-      signal_gen_block.add_file_source(args[0], factor)
-  else:
-    print '\nAt least 1 signal is required to run\n'
-    sys.exit(1)
+    if flag == '--periodic' or flag == '-p':
+      length = int(parameter.prefix)
+      duty = parameter.duty
+      frequency = parameter.frequency
+      factor = parameter.amplification
 
-  signal_gen_block.run()
+      signal_gen_block.add_periodic_signal(frequency, length, duty, factor)
+      signal_count += 1
+
+    if flag == '--bluetooth' or flag == '-b':
+      if not signal_params:
+        o.fatal('Config file required in order to use bluetooth option')
+
+      bt_params = signal_params['bluetooth']
+      # Round to nearest micro-second
+      signal_power_fraction = float(parameter.prefix)
+
+      length = bt_params['length']
+      duty = bt_params['duty']
+      factor = bt_params['amplitude'] * signal_power_fraction
+      profile = bt_params['profile']
+
+      signal_gen_block.add_periodic_profile(length, duty, factor, profile)
+      signal_count += 1
+
+    if flag == '--wifi' or flag == '-w':
+      if not signal_params:
+        o.fatal('Config file required in order to use wifi option')
+
+      params = signal_params['wifi']
+      # Round to nearest micro-second
+      signal_power_fraction = float(parameter.prefix)
+
+      length = params['length']
+      duty = params['duty']
+      factor = params['amplitude'] * signal_power_fraction
+      profile = params['profile']
+
+      signal_gen_block.add_periodic_profile(length, duty, factor, profile)
+      signal_count += 1
+
+  # If there are no inputted signals throw an error.
+  if signal_count == 0:
+    o.fatal('At least 1 signal is required to run')
+
+  try:
+    print 'Running...'
+    signal_gen_block.run()
+  except KeyboardInterrupt:
+    # Remove the Keyboard Interrupt Error from displaying
+    pass
+if __name__ == '__main__':
+  main()
+