#!/usr/bin/env python
#
# Collect and plot latency-profiling data from a running gpsd.
# Requires gnuplot.
#
import sys, os, time, getopt, gps, tempfile, time, socket, math, copy

class Baton:
    "Ship progress indication to stderr."
    def __init__(self, prompt, endmsg=None):
        self.stream = sys.stderr
        self.stream.write(prompt + "... \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)
        else:
            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)
        else:
            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
        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_num += 1
            if lon > lon_max :
                    lon_max = lon
        if alt_num == 0:
            alt_avg = gps.NaN
        else:
            alt_avg = alt_sum / alt_num
        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" \n' % (lon_max +1, alt_avg, alt_num, alt_avg)
        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 += "#"
        for hn in ("tag", "T1", "E1", "D1", "W", "E2", "T2", "D2"):
            res += "%8s\t" % hn
        res += "\n#"
        for i in range(0, 7):
            res += "--------\t"
        return res + "--------\n"
    def data(self, session):
        res = ""
        for timings in self.stats:
            if timings.sentence_time:
                e1 = timings.d_xmit_time
            else:
                e1 = 0
            res += "%s\t%2d\t%2.6f\t%2.6f\t%2.6f\t%2.6f\t%2.6f\t%2.6f\t%2.6f\n" \
                % (timings.sentence_tag,
                   timings.sentence_length,
                   e1, 
                   timings.d_recv_time,
                   timings.d_decode_time,
                   timings.poll_time,
                   timings.emit_time,
                   timings.c_recv_time,
                   timings.c_decode_time)
        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:9 title "D2 = Client decode time" with impulses, \
     "-" using 0:8 title "T2 =     TCP/IP latency" with impulses, \
     "-" using 0:7 title "E2 = Daemon encode time" with impulses, \
     "-" using 0:6 title "W  =     Poll wait time" with impulses, \
     "-" using 0:5 title "D1 = Daemon decode time" with impulses, \
     "-" using 0:4 title "T1 =         RS232 time" with impulses, \
     "-" using 0:3 title "E1 =        GPS latency" with impulses
'''
        res = fmt % (title,
                      session.gps_id, session.baudrate,
                      session.stopbits, session.cycle)
        res += self.header(session)
        for dummy in range(0, 7):
            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.sentence_tag not in self.sentences:
            self.sentences.append(session.timings.sentence_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", "W", "E2", "T2", "D2", "length"):
            res += "%8s\t" % hn
        res += "tag\n# "
        for s in tuple(splitplot.sentences) + ("T1", "D1", "W", "E2", "T2", "D2", "length"):
            res += "---------\t"
        return res + "--------\n"
    def data(self, session):
        res = ""
        for timings in self.stats:
            if timings.sentence_time:
                e1 = timings.d_xmit_time
            else:
                e1 = 0
            for s in splitplot.sentences:
                if s == timings.sentence_tag:
                    res += "%2.6f\t" % e1
                else:
                    res += "-       \t"
            res += "%2.6f\t%2.6f\t%2.6f\t%2.6f\t%2.6f\t%2.6f\t%8d\t# %s\n" \
                     % (timings.d_recv_time,
                        timings.d_decode_time,
                        timings.poll_time,
                        timings.emit_time,
                        timings.c_recv_time,
                        timings.c_decode_time,
                        timings.sentence_length,
                        timings.sentence_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 "W  = Poll wait 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+6,
                       sc+5,
                       sc+4,
                       sc+3,
                       sc+2,
                       sc+1)
        for i in range(sc):
            fmt += '     "-" using 0:%d title "%s" with impulses, \\\n' % \
                   (i+1, self.sentences[i])
        res = fmt[:-4] + "\n"
        res += self.header(session)
        for dummy in range(sc+6):
            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.sentence_tag[-3:] == "GSV" and last_command[-3:] == "GSV":
                continue
            last_command = timing.sentence_tag
            # Record timings
            received = timing.d_received()
            if not timing.sentence_tag in intervals:
                intervals[timing.sentence_tag] = []
            if timing.sentence_tag in last_seen:
                intervals[timing.sentence_tag].append(roundoff(received - last_seen[timing.sentence_tag])) 
            last_seen[timing.sentence_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()
    except socket.error:
        sys.stderr.write("gpsprof: gpsd unreachable.\n")
        sys.exit(1)
    try:
        if speed:
            session.query("b=%d\n" % speed)
            if session.baudrate != speed:
                sys.stderr.write("gpsprof: baud rate change failed.\n")
        session.query("w+bci\n")
        if formatter not in (spaceplot, uninstrumented):
            session.query("z+\n")
        #session.set_raw_hook(lambda x: sys.stderr.write(`x`+"\n"))
        baton = Baton("gpsprof: looking for fix", "done")
        countdown = await
        basetime = time.time()
        while countdown > 0:
            if session.poll() == None:
                sys.stderr.write("gpsprof: gpsd has vanished.\n")
                sys.exit(1)
            baton.twirl()
            if session.fix.mode <= gps.MODE_NO_FIX:
                continue
            if countdown == await:
                sys.stderr.write("first fix in %.2fsec, gathering samples..." % (time.time()-basetime,))
            # We can get some funky artifacts at start of session
            # apparently due to RS232 buffering effects. Ignore
            # them.
            if threshold and session.timings.c_decode_time > session.cycle * threshold:
                continue
            if plotter.gather(session):
                countdown -= 1
        baton.end()
    finally:
        session.query("w-z-\n")
    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:")
        formatter = "space"
        raw = False
        speed = 0
        title = time.ctime()
        threshold = 0
        await = 100
        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 == '-h'):
                sys.stderr.write(\
                    "usage: gpsprof [-h] [-r] [-m threshold] [-n samplecount] \n"
                     + "\t[-f {" + "|".join(map(lambda x: x.name, formatters)) + "}] [-s speed] [-t title] [-o file]\n")
                sys.exit(0)
        sys.stdout.write(plotframe(await,formatter,speed,threshold,title))
    except KeyboardInterrupt:
        pass


