#!/usr/local/bin/python2.6
#
# This file is Copyright (c) 2010 by the GPSD project
# BSD terms apply: see the file COPYING in the distribution root for details.
#
# Collect and plot latency-profiling data from a running gpsd.
# Requires gnuplot.
#
import sys, os, time, getopt, tempfile, time, socket, math, copy
import gps

class Baton:
    "Ship progress indication to stderr."
    def __init__(self, prompt, endmsg=None):
        self.stream = sys.stderr
        self.stream.write(prompt + "...")
        if os.isatty(self.stream.fileno()):
            self.stream.write(" \010")
        self.stream.flush()
        self.count = 0
        self.endmsg = endmsg
        self.time = time.time()
        return

    def twirl(self, ch=None):
        if self.stream is None:
            return
        if ch:
            self.stream.write(ch)
        elif os.isatty(self.stream.fileno()):
            self.stream.write("-/|\\"[self.count % 4])
            self.stream.write("\010")
        self.count = self.count + 1
        self.stream.flush()
        return

    def end(self, msg=None):
        if msg == None:
            msg = self.endmsg
        if self.stream:
            self.stream.write("...(%2.2f sec) %s.\n" % (time.time() - self.time, msg))
        return

class spaceplot:
    "Total times without instrumentation."
    name = "space"
    def __init__(self):
        self.fixes = []
    def d(self, a, b):
        return math.sqrt((a[0] - b[0])**2 + (a[1] - b[1])**2)
    def gather(self, session):
        # Include altitude, not used here, for 3D plot experiments.
        # Watch out for the NaN value from gps.py.
        self.fixes.append((session.fix.latitude, session.fix.longitude, session.fix.altitude))
        return True
    def header(self, session):
        res = "# Position uncertainty, %s, %s, %ds cycle\n" % \
                 (title, session.gps_id, session.cycle)
        return res
    def data(self, session):
        res = ""
        for i in range(len(self.recentered)):
            (lat, lon) = self.recentered[i][:2]
            (raw1, raw2, alt) = self.fixes[i]
            res += "%f\t%f\t%f\t%f\t%f\n" % (lat, lon, raw1, raw2, alt)
        return res
    def plot(self, title, session):
        if len(self.fixes) == 0:
            sys.stderr.write("No fixes collected, can't estimate accuracy.")
            sys.exit(1)
	# centroid is just arithmetic avg of lat,lon
        self.centroid = (sum(map(lambda x:x[0], self.fixes))/len(self.fixes), sum(map(lambda x:x[1], self.fixes))/len(self.fixes))
        # Sort fixes by distance from centroid
        self.fixes.sort(lambda x, y: cmp(self.d(self.centroid, x), self.d(self.centroid, y)))
        # Convert fixes to offsets from centroid in meters
        self.recentered = map(lambda fix: gps.MeterOffset(self.centroid, fix[:2]), self.fixes)
        # Compute CEP(50%)
        cep_meters = gps.EarthDistance(self.centroid[:2], self.fixes[len(self.fixes)/2][:2])
        alt_sum = 0
        alt_num = 0
        alt_fixes = []
        lon_max = -9999
        for i in range(len(self.recentered)):
            (lat, lon) = self.recentered[i][:2]
            (raw1, raw2, alt) = self.fixes[i]
            if not gps.isnan(alt):
                    alt_sum += alt
                    alt_fixes.append( alt)
                    alt_num += 1
            if lon > lon_max :
                    lon_max = lon
        if alt_num == 0:
            alt_avg = gps.NaN
            alt_ep = gps.NaN
        else:
            alt_avg = alt_sum / alt_num
            # Sort fixes by distance from average altitude
            alt_fixes.sort(lambda x, y: cmp(abs(alt_avg - x), abs(alt_avg - y)))
            alt_ep = abs( alt_fixes[ len(alt_fixes)/2 ] - alt_avg)
        if self.centroid[0] < 0:
            latstring = "%fS" % -self.centroid[0]
        elif self.centroid[0] == 0:
            latstring = "0"
        else:
            latstring = "%fN" % self.centroid[0]
        if self.centroid[1] < 0:
            lonstring = "%fW" % -self.centroid[1]
        elif self.centroid[1] == 0:
            lonstring = "0"
        else:
            lonstring = "%fE" % self.centroid[1]
        fmt = "set autoscale\n"
        fmt += 'set key below\n'
        fmt += 'set key title "%s"\n' % time.asctime()
        fmt += 'set size ratio -1\n'
        fmt += 'set style line 2 pt 1\n'
        fmt += 'set style line 3 pt 2\n'
        fmt += 'set xlabel "Meters east from %s"\n' % lonstring
        fmt += 'set ylabel "Meters north from %s"\n' % latstring
        fmt += 'set border 15\n'
        if not gps.isnan(alt_avg):
            fmt += 'set y2label "Meters Altitude from %f"\n' % alt_avg
            fmt += 'set ytics nomirror\n'
            fmt += 'set y2tics\n'
        fmt += 'cep=%f\n' % self.d((0,0), self.recentered[len(self.fixes)/2])
        fmt += 'set parametric\n'
        fmt += 'set trange [0:2*pi]\n'
        fmt += 'cx(t, r) = sin(t)*r\n'
        fmt += 'cy(t, r) = cos(t)*r\n'
        fmt += 'chlen = cep/20\n'
        fmt += "set arrow from -chlen,0 to chlen,0 nohead\n"
        fmt += "set arrow from 0,-chlen to 0,chlen nohead\n"
        fmt += 'plot  cx(t, cep),cy(t, cep) title "CEP (50%%) = %f meters",  ' % (cep_meters)
        fmt += ' "-" using 1:2 with points ls 3 title "%d GPS fixes" ' % (len(self.fixes))
        if not gps.isnan(alt_avg):
            fmt += ', "-" using ( %f ):($5 < 100000 ? $5 - %f : 1/0) axes x1y2 with points ls 2 title " %d Altitude fixes, Average = %f, EP (50%%) = %f" \n' % (lon_max +1, alt_avg, alt_num, alt_avg, alt_ep)
        else:
            fmt += "\n"
        fmt += self.header(session)
        fmt += self.data(session)
        if not gps.isnan(alt_avg):
            fmt += "e\n" + self.data(session)
        return fmt

class uninstrumented:
    "Total times without instrumentation."
    name = "uninstrumented"
    def __init__(self):
        self.stats = []
    def gather(self, session):
        if session.fix.time:
            seconds = time.time() - session.fix.time
            self.stats.append(seconds)
            return True
        else:
            return False
    def header(self, session):
        return "# Uninstrumented total latency, %s, %s, %dN%d, cycle %ds\n" % \
                 (title,
                  session.gps_id, session.baudrate,
                  session.stopbits, session.cycle)
    def data(self, session):
        res = ""
        for seconds in self.stats:
            res += "%2.6lf\n" % seconds
        return res
    def plot(self, title, session):
        fmt = '''
set autoscale
set key below
set key title "Uninstrumented total latency, %s, %s, %dN%d, cycle %ds"
plot "-" using 0:1 title "Total time" with impulses
'''
        res = fmt % (title,
                      session.gps_id, session.baudrate,
                      session.stopbits, session.cycle)
        res += self.header(session)
        return res + self.data(session)

class rawplot:
    "All measurement, no deductions."
    name = "raw"
    def __init__(self):
        self.stats = []
    def gather(self, session):
        self.stats.append(copy.copy(session.timings))
        return True
    def header(self, session):
        res = "# Raw latency data, %s, %s, %dN%d, cycle %ds\n" % \
                 (title,
                  session.gps_id, session.baudrate,
                  session.stopbits, session.cycle)
        res += "# tag    len    xmit                  "
        for hn in ("T1", "D1", "E2", "T2", "D2"):
            res += "%-13s" % hn
        res += "\n#------- -----  --------------------"
        for i in range(0, 5):
            res += "  " + ("-" * 11)
        return res + "\n"
    def data(self, session):
        res = ""
        for timings in self.stats:
            res += "% 8s  %4d  %2.9f  %2.9f  %2.9f  %2.9f  %2.9f  %2.9f\n" \
                % (timings.tag,
                   timings.len,
                   timings.xmit, 
                   timings.recv - timings.xmit,
                   timings.decode - timings.recv,
                   timings.emit - timings.decode,
                   timings.c_recv - timings.emit,
                   timings.c_decode - timings.c_recv)
        return res
    def plot(self, file, session):
        fmt = '''
set autoscale
set key below
set key title "Raw latency data, %s, %s, %dN%d, cycle %ds"
plot \
     "-" using 0:8 title "D2 = Client decode time" with impulses, \
     "-" using 0:7 title "T2 =     TCP/IP latency" with impulses, \
     "-" using 0:6 title "E2 = Daemon encode time" with impulses, \
     "-" using 0:5 title "D1 = Daemon decode time" with impulses, \
     "-" using 0:4 title "T1 =         RS232 time" with impulses
'''
        res = fmt % (title,
                      session.gps_id, session.baudrate,
                      session.stopbits, session.cycle)
        res += self.header(session)
        for dummy in range(0, 5):
            res += self.data(session) + "e\n"
        return res

class splitplot:
    "Discard base time, use color to indicate different tags."
    name = "split"
    sentences = []
    def __init__(self):
        self.stats = []
    def gather(self, session):
        self.stats.append(copy.copy(session.timings))
        if session.timings.tag not in self.sentences:
            self.sentences.append(session.timings.tag)
        return True
    def header(self, session):
        res = "# Split latency data, %s, %s, %dN%d, cycle %ds\n#" % \
                 (title,
                  session.gps_id, session.baudrate,
                  session.stopbits, session.cycle)
        for s in splitplot.sentences:
            res += "%8s\t" % s
        for hn in ("T1", "D1", "E2", "T2", "D2", "length"):
            res += "%8s\t" % hn
        res += "tag\n# "
        for s in tuple(splitplot.sentences) + ("T1", "D1", "E2", "T2", "D2", "length"):
            res += "---------\t"
        return res + "--------\n"
    def data(self, session):
        res = ""
        for timings in self.stats:
            for s in splitplot.sentences:
                if s == timings.tag:
                    res += "%2.6f\t" % timings.xmit
                else:
                    res += "-       \t"
            res += "%2.6f\t%2.6f\t%2.6f\t%2.6f\t%2.6f\t%8d\t# %s\n" \
                     % (timings.recv - timings.xmit,
                        timings.decode - timings.recv,
                        timings.emit - timings.decode,
                        timings.c_recv - timings.emit,
                        timings.c_decode - timings.c_recv,
                        timings.len,
                        timings.tag)
        return res
    def plot(self, title, session):
        fixed = '''
set autoscale
set key below
set key title "Filtered latency data, %s, %s, %dN%d, cycle %ds"
plot \
     "-" using 0:%d title "D2 = Client decode time" with impulses, \
     "-" using 0:%d title "T2 = TCP/IP latency" with impulses, \
     "-" using 0:%d title "E2 = Daemon encode time" with impulses, \
     "-" using 0:%d title "D1 = Daemon decode time" with impulses, \
     "-" using 0:%d title "T1 = RS3232 time" with impulses, \
'''
        sc = len(splitplot.sentences)
        fmt = fixed % (title,
                       session.gps_id, session.baudrate,
                       session.stopbits, session.cycle,
                       sc+5,
                       sc+4,
                       sc+3,
                       sc+2,
                       sc+1)
        for i in range(sc):
            fmt += '     "-" using 0:%d title "%s" with impulses,' % \
                   (i+1, self.sentences[i])
        res = fmt[:-1] + "\n"
        res += self.header(session)
        for dummy in range(sc+5):
            res += self.data(session) + "e\n"
        return res

class cycle:
    "Send-cycle analysis."
    name = "cycle"
    def __init__(self):
        self.stats = []
    def gather(self, session):
        self.stats.append(copy.copy(session.timings))
        return True
    def plot(self, title, session):
        msg = ""
        def roundoff(n):
            # Round a time to hundredths of a second
            return round(n*100) / 100.0
        intervals = {}
        last_seen = {}
        for timing in self.stats:
            # Throw out everything but the leader in each GSV group
            if timing.tag[-3:] == "GSV" and last_command[-3:] == "GSV":
                continue
            last_command = timing.tag
            # Record timings
            received = timing.d_received()
            if not timing.tag in intervals:
                intervals[timing.tag] = []
            if timing.tag in last_seen:
                intervals[timing.tag].append(roundoff(received - last_seen[timing.tag])) 
            last_seen[timing.tag] = received

        # Step three: get command frequencies and the basic send cycle time
        frequencies = {}
        for (key, interval_list) in intervals.items():
            frequencies[key] = {}
            for interval in interval_list:
                frequencies[key][interval] = frequencies[key].get(interval, 0) + 1
        # filter out noise
        for key in frequencies:
            distribution = frequencies[key]
            for interval in distribution.keys():
                if distribution[interval] < 2:
                    del distribution[interval]
        cycles = {}
        for key in frequencies:
            distribution = frequencies[key]
            if len(frequencies[key].values()) == 1:
                # The value is uniqe after filtering
                cycles[key] = distribution.keys()[0]
            else:
                # Compute the mode
                maxfreq = 0
                for (interval, frequency) in distribution.items():
                    if distribution[interval] > maxfreq:
                        cycles[key] = interval
                        maxfreq = distribution[interval]
        msg += "Cycle report %s, %s, %dN%d, cycle %ds" % \
                 (title,
                  session.gps_id, session.baudrate,
                  session.stopbits, session.cycle)
        msg += "The sentence set emitted by this GPS is: %s\n" % " ".join(intervals.keys())
        for key in cycles:
            if len(frequencies[key].values()) == 1:
                if cycles[key] == 1:
                    msg += "%s: is emitted once a second.\n" % key
                else:
                    msg += "%s: is emitted once every %d seconds.\n" % (key, cycles[key])
            else:
                if cycles[key] == 1:
                    msg += "%s: is probably emitted once a second.\n" % key
                else:
                    msg += "%s: is probably emitted once every %d seconds.\n" % (key, cycles[key])
        sendcycle = min(*cycles.values())
        if sendcycle == 1:
            msg += "Send cycle is once per second.\n"
        else:
            msg += "Send cycle is once per %d seconds.\n" % sendcycle
        return msg

formatters = (spaceplot, uninstrumented, rawplot, splitplot, cycle)

def plotframe(await, fname, speed, threshold, title):
    "Return a string containing a GNUplot script "
    if fname:
        for formatter in formatters:
            if formatter.name == fname:
                plotter = formatter()
                break
        else:
            sys.stderr.write("gpsprof: no such formatter.\n")
            sys.exit(1)
    try:
        session = gps.gps(verbose=verbose)
    except socket.error:
        sys.stderr.write("gpsprof: gpsd unreachable.\n")
        sys.exit(1)
    # Initialize
    session.poll()
    if session.version == None:
        print >>sys.stderr, "gpsprof: requires gpsd to speak new protocol."
        sys.exit(1)
    session.send("?DEVICES;")
    while session.poll() != -1:
        if session.data["class"] == "DEVICES":
            break
    if len(session.data.devices) != 1:
        print >>sys.stderr, "gpsprof: exactly one device must be attached."
        sys.exit(1)
    device = session.data.devices[0]
    path = device["path"]
    session.baudrate = device["bps"] 
    session.parity = device["bps"] 
    session.stopbits = device["stopbits"] 
    session.cycle = device["cycle"]
    session.gps_id = device["driver"]
    # Set parameters
    if speed:
        session.send('?DEVICE={"path":"%s","bps:":%d}' % (path, speed))
        session.poll()
        if session.baudrate != speed:
            sys.stderr.write("gpsprof: baud rate change failed.\n")
    options = ""
    if formatter not in (spaceplot, uninstrumented):
        options = ',"timing":true'
    try:
        #session.set_raw_hook(lambda x: sys.stderr.write(`x`+"\n"))
        session.send('?WATCH={"enable":true%s}' % options)
        baton = Baton("gpsprof: looking for fix", "done")
        countdown = await
        basetime = time.time()
        while countdown > 0:
            if session.poll() == -1:
                sys.stderr.write("gpsprof: gpsd has vanished.\n")
                sys.exit(1)
            baton.twirl()
            if session.data["class"] == "WATCH":
                if "timing" in options and not session.data.get("timing"):
                    sys.stderr.write("gpsprof: timing is not enabled.\n")
                    sys.exit(1)
            # We can get some funky artifacts at start of session
            # apparently due to RS232 buffering effects. Ignore
            # them.
            if threshold and time.time()-basetime < session.cycle * threshold:
                continue
            if session.fix.mode <= gps.MODE_NO_FIX:
                continue
            if countdown == await:
                sys.stderr.write("first fix in %.2fsec, gathering %d samples..." % (time.time()-basetime,await))
            if plotter.gather(session):
                countdown -= 1
        baton.end()
    finally:
        session.send('?WATCH={"enable":false,"timing":false}')
    command = plotter.plot(title, session)
    del session
    return command

if __name__ == '__main__':
    try:
	(options, arguments) = getopt.getopt(sys.argv[1:], "f:hm:n:s:t:D:")

        formatter = "space"
        raw = False
        speed = 0
        title = time.ctime()
        threshold = 0
        await = 100
        verbose = 0
        for (switch, val) in options:
            if (switch == '-f'):
                formatter = val
            elif (switch == '-m'):
                threshold = int(val)
            elif (switch == '-n'):
                await = int(val)
            elif (switch == '-s'):
                speed = int(val)
            elif (switch == '-t'):
                title = val
            elif (switch == '-D'):
                verbose = int(val)
            elif (switch == '-h'):
                sys.stderr.write(\
                    "usage: gpsprof [-h] [-D debuglevel] [-m threshold] [-n samplecount] \n"
                     + "\t[-f {" + "|".join(map(lambda x: x.name, formatters)) + "}] [-s speed] [-t title]\n")
                sys.exit(0)
        sys.stdout.write(plotframe(await,formatter,speed,threshold,title))
    except KeyboardInterrupt:
        pass

# The following sets edit modes for GNU EMACS
# Local Variables:
# mode:python
# End:
