gpsprof.in 42 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245
  1. #!@PYSHEBANG@
  2. #
  3. # @GENERATED@
  4. '''
  5. Collect and plot latency-profiling data from a running gpsd.
  6. Requires gnuplot, but gnuplot can be on another host.
  7. '''
  8. # This file is Copyright 2010 by the GPSD project
  9. # SPDX-License-Identifier: BSD-2-clause
  10. #
  11. # Updated to conform with RCC-219-00, RCC/IRIG Standard 261-00
  12. # "STANDARD REPORT FORMAT FOR GLOBAL POSITIONING SYSTEM (GPS) RECEIVERS AND
  13. # SYSTEMS ACCURACY TESTS AND EVALUATIONS"
  14. #
  15. # TODO: put date from data on plot, not time of replot.
  16. # TODO: add lat/lon to polar plots
  17. #
  18. # This code runs compatibly under Python 2 and 3.x for x >= 2.
  19. # Preserve this property!
  20. from __future__ import absolute_import, print_function, division
  21. import copy
  22. import getopt
  23. import math
  24. import os
  25. import signal
  26. import socket
  27. import sys
  28. import time
  29. # pylint wants local modules last
  30. try:
  31. import gps
  32. except ImportError as e:
  33. sys.stderr.write(
  34. "gpsprof: can't load Python gps libraries -- check PYTHONPATH.\n")
  35. sys.stderr.write("%s\n" % e)
  36. sys.exit(1)
  37. gps_version = '@VERSION@'
  38. if gps.__version__ != gps_version:
  39. sys.stderr.write("gpsprof: ERROR: need gps module version %s, got %s\n" %
  40. (gps_version, gps.__version__))
  41. sys.exit(1)
  42. debug = False
  43. def dist_2d(a, b):
  44. "calculate distance between a[x,y] and b[x,y]"
  45. # x and y are orthogonal, probably lat/lon in meters
  46. # ignore altitude change.
  47. return math.sqrt((a[0] - b[0]) ** 2 + (a[1] - b[1]) ** 2)
  48. def dist_3d(a, b):
  49. "calculate distance between a[x,y,z] and b[x,y,z]"
  50. # x, y, and z are othogonal, probably ECEF, probably in meters
  51. return math.sqrt((a[0] - b[0]) ** 2 +
  52. (a[1] - b[1]) ** 2 +
  53. (a[2] - b[2]) ** 2)
  54. def wgs84_to_ecef(wgs84):
  55. "Convert wgs84 coordinates to ECEF ones"
  56. # unpack args
  57. (lat, lon, alt) = wgs84
  58. # convert lat/lon/altitude in degrees and altitude in meters
  59. # to ecef x, y, z in meters
  60. # see
  61. # http://www.mathworks.de/help/toolbox/aeroblks/llatoecefposition.html
  62. lat = math.radians(lat)
  63. lon = math.radians(lon)
  64. rad = 6378137.0 # Radius of the Earth (in meters)
  65. f = 1.0 / 298.257223563 # Flattening factor WGS84 Model
  66. cosLat = math.cos(lat)
  67. sinLat = math.sin(lat)
  68. FF = (1.0 - f) ** 2
  69. C = 1 / math.sqrt((cosLat ** 2) + (FF * sinLat ** 2))
  70. S = C * FF
  71. x = (rad * C + alt) * cosLat * math.cos(lon)
  72. y = (rad * C + alt) * cosLat * math.sin(lon)
  73. z = (rad * S + alt) * sinLat
  74. return (x, y, z)
  75. class Baton(object):
  76. "Ship progress indication to stderr."
  77. def __init__(self, prompt, endmsg=None):
  78. self.stream = sys.stderr
  79. self.stream.write(prompt + "...")
  80. if os.isatty(self.stream.fileno()):
  81. self.stream.write(" \b")
  82. self.stream.flush()
  83. self.count = 0
  84. self.endmsg = endmsg
  85. self.time = time.time()
  86. def twirl(self, ch=None):
  87. "Twirl the baton"
  88. if self.stream is None:
  89. return
  90. if ch:
  91. self.stream.write(ch)
  92. elif os.isatty(self.stream.fileno()):
  93. self.stream.write("-/|\\"[self.count % 4])
  94. self.stream.write("\b")
  95. self.count = self.count + 1
  96. self.stream.flush()
  97. return
  98. def end(self, msg=None):
  99. "Write the end message"
  100. if msg is None:
  101. msg = self.endmsg
  102. if self.stream:
  103. self.stream.write("...(%2.2f sec) %s.\n"
  104. % (time.time() - self.time, msg))
  105. class stats(object):
  106. "Class for 1D stats: min, max, mean, sigma, skewness, kurtosis"
  107. def __init__(self):
  108. self.min = 0.0
  109. self.max = 0.0
  110. self.mean = 0.0
  111. self.median = 0.0
  112. self.sigma = 0.0
  113. self.skewness = 0.0
  114. self.kurtosis = 0.0
  115. def __str__(self):
  116. "return a nice string, for debug"
  117. return ("min %f, max %f, mean %f, median %f, sigma %f, skewedness %f, "
  118. "kurtosis %f" %
  119. (self.min, self.max, self.mean, self.median,
  120. self.sigma, self.skewness, self.kurtosis))
  121. def min_max_mean(self, fixes, index):
  122. "Find min, max, and mean of fixes[index]"
  123. if not fixes:
  124. return
  125. # might be fast to go through list once?
  126. if isinstance(fixes[0], tuple):
  127. self.mean = sum([x[index] for x in fixes]) / len(fixes)
  128. self.min = min([x[index] for x in fixes])
  129. self.max = max([x[index] for x in fixes])
  130. else:
  131. # must be float
  132. self.mean = sum(fixes) / len(fixes)
  133. self.min = min(fixes)
  134. self.max = max(fixes)
  135. return
  136. def moments(self, fixes, index):
  137. "Find and set the (sigma, skewness, kurtosis) of fixes[index]"
  138. # The skewness of a random variable X is the third standardized
  139. # moment and is a dimension-less ratio. ntpviz uses the Pearson's
  140. # moment coefficient of skewness. Wikipedia describes it
  141. # best: "The qualitative interpretation of the skew is complicated
  142. # and unintuitive." A normal distribution has a skewness of zero.
  143. self.skewness = float('nan')
  144. # The kurtosis of a random variable X is the fourth standardized
  145. # moment and is a dimension-less ratio. Here we use the Pearson's
  146. # moment coefficient of kurtosis. A normal distribution has a
  147. # kurtosis of three. NIST describes a kurtosis over three as
  148. # "heavy tailed" and one under three as "light tailed".
  149. self.kurtosis = float('nan')
  150. if not fixes:
  151. return
  152. m3 = 0.0
  153. m4 = 0.0
  154. if isinstance(fixes[0], tuple):
  155. sum_squares = [(x[index] - self.mean) ** 2 for x in fixes]
  156. sigma = math.sqrt(sum(sum_squares) / (len(fixes) - 1))
  157. for fix in fixes:
  158. m3 += pow(fix[index] - sigma, 3)
  159. m4 += pow(fix[index] - sigma, 4)
  160. else:
  161. # must be float
  162. sum_squares = [(x - self.mean) ** 2 for x in fixes]
  163. sigma = math.sqrt(sum(sum_squares) / (len(fixes) - 1))
  164. for fix in fixes:
  165. m3 += pow(fix - sigma, 3)
  166. m4 += pow(fix - sigma, 4)
  167. self.sigma = sigma
  168. if sigma > 0.0001:
  169. self.skewness = m3 / (len(fixes) * pow(sigma, 3))
  170. self.kurtosis = m4 / (len(fixes) * pow(sigma, 4))
  171. return
  172. class plotter(object):
  173. "Generic class for gathering and plotting sensor statistics."
  174. def __init__(self):
  175. self.device = None
  176. self.fixes = []
  177. self.in_replot = False
  178. self.session = None
  179. self.start_time = int(time.time())
  180. self.watch = set(['TPV'])
  181. def whatami(self):
  182. "How do we identify this plotting run?"
  183. desc = "%s, %s, " % \
  184. (gps.misc.isotime(self.start_time),
  185. self.device.get('driver', "unknown"))
  186. if 'bps' in self.device:
  187. desc += "%d %dN%d, cycle %.3gs" % \
  188. (self.device['bps'], 9 - self.device['stopbits'],
  189. self.device['stopbits'], self.device['cycle'])
  190. else:
  191. desc += self.device['path']
  192. if 'subtype' in self.device:
  193. desc += "\\n%s" % self.device['subtype']
  194. return desc
  195. def collect(self, verb, log_fp=None):
  196. "Collect data from the GPS."
  197. try:
  198. self.session = gps.gps(host=host, port=port, verbose=verb)
  199. except socket.error:
  200. sys.stderr.write("gpsprof: gpsd unreachable.\n")
  201. sys.exit(1)
  202. # Initialize
  203. self.session.read()
  204. if self.session.version is None:
  205. sys.stderr.write("gpsprof: requires gpsd to speak new protocol.\n")
  206. sys.exit(1)
  207. # Set parameters
  208. flags = gps.WATCH_ENABLE | gps.WATCH_JSON
  209. if self.requires_time:
  210. flags |= gps.WATCH_TIMING
  211. if device:
  212. flags |= gps.WATCH_DEVICE
  213. try:
  214. signal.signal(signal.SIGUSR1,
  215. lambda empty, unused: sys.stderr.write(
  216. "%d of %d (%d%%)..."
  217. % (wait - countdown, wait,
  218. ((wait - countdown) * 100.0 / wait))))
  219. signal.siginterrupt(signal.SIGUSR1, False)
  220. self.session.stream(flags, device)
  221. baton = Baton("gpsprof: %d looking for fix" % os.getpid(), "done")
  222. countdown = wait
  223. basetime = time.time()
  224. while countdown > 0:
  225. if self.session.read() == -1:
  226. sys.stderr.write("gpsprof: gpsd has vanished.\n")
  227. sys.exit(1)
  228. baton.twirl()
  229. if self.session.data["class"] == "ERROR":
  230. sys.stderr.write(" ERROR: %s.\n"
  231. % self.session.data["message"])
  232. sys.exit(1)
  233. if self.session.data["class"] == "DEVICES":
  234. if len(self.session.data["devices"]) != 1 and not device:
  235. sys.stderr.write("ERROR: multiple devices connected, "
  236. "you must explicitly specify the "
  237. "device.\n")
  238. sys.exit(1)
  239. for i in range(len(self.session.data["devices"])):
  240. self.device = copy.copy(
  241. self.session.data["devices"][i])
  242. if self.device['path'] == device:
  243. break
  244. if self.session.data["class"] == "WATCH":
  245. if ((self.requires_time and
  246. not self.session.data.get("timing"))):
  247. sys.stderr.write("timing is not enabled.\n")
  248. sys.exit(1)
  249. # Log before filtering - might be good for post-analysis.
  250. if log_fp:
  251. log_fp.write(self.session.response)
  252. # Ignore everything but what we're told to
  253. if self.session.data["class"] not in self.watch:
  254. continue
  255. # We can get some funky artifacts at start of self.session
  256. # apparently due to RS232 buffering effects. Ignore
  257. # them.
  258. if ((threshold and
  259. time.time() - basetime < self.session.cycle * threshold)):
  260. continue
  261. if self.session.fix.mode <= gps.MODE_NO_FIX:
  262. continue
  263. if self.sample():
  264. if countdown == wait:
  265. sys.stderr.write("first fix in %.2fsec, gathering %d "
  266. "samples..."
  267. % (time.time() - basetime, wait))
  268. countdown -= 1
  269. baton.end()
  270. finally:
  271. self.session.stream(gps.WATCH_DISABLE | gps.WATCH_TIMING)
  272. signal.signal(signal.SIGUSR1, signal.SIG_DFL)
  273. def replot(self, infp):
  274. "Replot from a JSON log file."
  275. self.in_replot = True
  276. baton = Baton("gpsprof: replotting", "done")
  277. self.session = gps.gps(host=None)
  278. for line in infp:
  279. baton.twirl()
  280. self.session.unpack(line)
  281. if self.session.data["class"] == "DEVICES":
  282. self.device = copy.copy(self.session.data["devices"][0])
  283. elif self.session.data["class"] not in self.watch:
  284. continue
  285. self.sample()
  286. baton.end()
  287. def dump(self):
  288. "Dump the raw data for post-analysis."
  289. return self.header() + self.data()
  290. class spaceplot(plotter):
  291. "Spatial scattergram of fixes."
  292. name = "space"
  293. requires_time = False
  294. def __init__(self):
  295. "Initialize class spaceplot"
  296. plotter.__init__(self)
  297. self.centroid = None
  298. self.centroid_ecef = None
  299. self.recentered = []
  300. def sample(self):
  301. "Grab samples"
  302. # Watch out for the NaN value from gps.py.
  303. if (((self.in_replot or self.session.valid) and
  304. self.session.data["class"] == "TPV" and
  305. # Skip TPV without an actual fix.
  306. self.session.data["mode"] >= gps.MODE_2D)):
  307. # get sat used count
  308. sats_used = 0
  309. for sat in self.session.satellites:
  310. if sat.used:
  311. sats_used += 1
  312. if 'altHAE' not in self.session.data:
  313. self.session.data['altHAE'] = gps.NaN
  314. self.fixes.append((self.session.data['lat'],
  315. self.session.data['lon'],
  316. self.session.data['altHAE'], sats_used))
  317. return True
  318. def header(self):
  319. "Return header"
  320. return "\n# Position uncertainty, %s\n" % self.whatami()
  321. def postprocess(self):
  322. "Postprocess the sample data"
  323. return
  324. def data(self):
  325. "Format data for dump"
  326. res = ""
  327. for i in range(len(self.recentered)):
  328. (lat, lon) = self.recentered[i][:2]
  329. (raw1, raw2, alt) = self.fixes[i]
  330. res += "%.9f\t%.9f\t%.9f\t%.9f\t%.9f\n" \
  331. % (lat, lon, raw1, raw2, alt)
  332. return res
  333. def plot(self):
  334. "Plot the data"
  335. stat_lat = stats()
  336. stat_lon = stats()
  337. stat_alt = stats()
  338. stat_used = stats()
  339. # recentered stats
  340. stat_lat_r = stats()
  341. stat_lon_r = stats()
  342. stat_alt_r = stats()
  343. if len(self.fixes) == 0:
  344. print("plot(): fixes array is empty\n")
  345. raise Exception('When plotting, there are no fixes.')
  346. sats_used = []
  347. for x in self.fixes:
  348. # skip missing sats, if any, often missing at start
  349. if x[3] != 0:
  350. sats_used.append(x[3])
  351. # calc sats used data: mean, min, max, sigma
  352. stat_used.min_max_mean(sats_used, 0)
  353. stat_used.moments(sats_used, 0)
  354. # find min, max and mean of lat/lon
  355. stat_lat.min_max_mean(self.fixes, 0)
  356. stat_lon.min_max_mean(self.fixes, 1)
  357. # centroid is just arithmetic avg of lat,lon
  358. self.centroid = (stat_lat.mean, stat_lon.mean)
  359. # Sort fixes by distance from centroid
  360. # sorted to make getting CEP() easy
  361. self.fixes.sort(key=lambda p: dist_2d(self.centroid, p[:2]))
  362. # compute min/max as meters, ignoring altitude
  363. # EarthDistance always returns a positive value
  364. lat_min_o = -gps.EarthDistance((stat_lat.min, self.centroid[1]),
  365. self.centroid[:2])
  366. lat_max_o = gps.EarthDistance((stat_lat.max, self.centroid[1]),
  367. self.centroid[:2])
  368. lon_min_o = -gps.EarthDistance((self.centroid[0], stat_lon.min),
  369. self.centroid[:2])
  370. lon_max_o = gps.EarthDistance((self.centroid[0], stat_lon.max),
  371. self.centroid[:2])
  372. # Convert fixes to offsets from centroid in meters
  373. self.recentered = [
  374. gps.MeterOffset(fix[:2], self.centroid) for fix in self.fixes]
  375. stat_lat_r.min_max_mean(self.recentered, 0)
  376. stat_lon_r.min_max_mean(self.recentered, 1)
  377. # compute sigma, skewness and kurtosis of lat/lon
  378. stat_lat_r.moments(self.recentered, 0)
  379. stat_lon_r.moments(self.recentered, 1)
  380. # CEP(50) calculated per RCC 261-00, Section 3.1.1
  381. # Note that this is not the distance such that 50% of the
  382. # points are within that distance; it is a distance calculated
  383. # from the standard deviation as specified by a standard, and
  384. # is likely a true CEP(50) if the distribution meets
  385. # expectations, such as normality. However, it seems in
  386. # general to be close to the actual population CEP(50).
  387. calc_cep = 0.5887 * (stat_lat_r.sigma + stat_lon_r.sigma)
  388. # 2DRMS calculated per RCC 261-00, Section 3.1.4
  389. calc_2drms = 2 * math.sqrt(stat_lat_r.sigma ** 2 +
  390. stat_lon_r.sigma ** 2)
  391. # Note that the fencepost error situation in the following is
  392. # not entirely clear. Issues include CEP(50) = x being
  393. # defined as 50% of the points being < x distance vs <= x
  394. # distance, and the interaction of truncation of len*0.5 vs
  395. # zero-based arrays. (If you care about this because you are
  396. # concerned about your results, then you almost certainly are
  397. # not using enough samples.)
  398. # Compute actual CEP(50%) for our input set. This makes no
  399. # assumptions about the distribution being normal, etc. -- it
  400. # is literally finding the distance such that 50% of our
  401. # samples are within that distance. We simply find the
  402. # distance of the point whose index is half the number of
  403. # points, after having sorted the points by distance.
  404. cep_meters = gps.misc.EarthDistance(
  405. self.centroid[:2], self.fixes[int(len(self.fixes) * 0.50)][:2])
  406. # Compute actual CEP(95%) for our input set. As above, but
  407. # index at 0.95 in the sorted points, so that 95% of points
  408. # are closer, 5% farther.
  409. cep95_meters = gps.misc.EarthDistance(
  410. self.centroid[:2], self.fixes[int(len(self.fixes) * 0.95)][:2])
  411. # Compute actual CEP(99%) for our input set. As above, but
  412. # index at 0.99 in the sorted points, so that 99% of points
  413. # are closer, 1% farther.
  414. cep99_meters = gps.misc.EarthDistance(
  415. self.centroid[:2], self.fixes[int(len(self.fixes) * 0.99)][:2])
  416. # Compute actual CEP(100%) for our input set. This is the
  417. # maximum distance from the centroid for any point in the
  418. # input set, and hence the distance of last point in sorted order.
  419. cep100_meters = gps.misc.EarthDistance(
  420. self.centroid[:2], self.fixes[len(self.fixes) - 1][:2])
  421. # init altitude data
  422. alt_ep = gps.NaN
  423. alt_ep95 = gps.NaN
  424. alt_ep99 = gps.NaN
  425. dist_3d_max = 0.0
  426. alt_fixes = []
  427. alt_fixes_r = []
  428. latlon_data = ""
  429. alt_data = ""
  430. # init calculated hep, sep and mrse
  431. calc_hep = gps.NaN
  432. calc_sep = gps.NaN
  433. calc_mrse = gps.NaN
  434. # grab and format the fixes as gnuplot will use them
  435. for i in range(len(self.recentered)):
  436. # grab valid lat/lon data, recentered and raw
  437. (lat, lon) = self.recentered[i][:2]
  438. alt = self.fixes[i][2]
  439. latlon_data += "%.9f\t%.9f\n" % (lat, lon)
  440. if not math.isnan(alt):
  441. # only keep good fixes
  442. alt_fixes.append(alt)
  443. # micro meters should be good enough
  444. alt_data += "%.6f\n" % (alt)
  445. if alt_fixes:
  446. # got altitude data
  447. # Convert fixes to offsets from avg in meters
  448. alt_data_centered = ""
  449. # find min, max and mean of altitude
  450. stat_alt.min_max_mean(alt_fixes, 0)
  451. for alt in alt_fixes:
  452. alt_fixes_r.append(alt - stat_alt.mean)
  453. alt_data_centered += "%.6f\n" % (alt - stat_alt.mean)
  454. stat_alt_r.min_max_mean(alt_fixes_r, 0)
  455. stat_alt_r.moments(alt_fixes_r, 0)
  456. # centroid in ECEF
  457. self.centroid_ecef = wgs84_to_ecef([stat_lat.mean,
  458. stat_lon.mean,
  459. stat_alt.mean])
  460. # once more through the data, looking for 3D max
  461. for fix_lla in self.fixes:
  462. if not math.isnan(fix_lla[2]):
  463. fix_ecef = wgs84_to_ecef(fix_lla[:3])
  464. dist3d = dist_3d(self.centroid_ecef, fix_ecef)
  465. if dist_3d_max < dist3d:
  466. dist_3d_max = dist3d
  467. # Sort fixes by distance from average altitude
  468. alt_fixes_r.sort(key=abs)
  469. # so we can rank fixes for EPs
  470. alt_ep = abs(alt_fixes_r[int(len(alt_fixes_r) * 0.50)])
  471. alt_ep95 = abs(alt_fixes_r[int(len(alt_fixes_r) * 0.95)])
  472. alt_ep99 = abs(alt_fixes_r[int(len(alt_fixes_r) * 0.99)])
  473. # HEP(50) calculated per RCC 261-00, Section 3.1.2
  474. calc_hep = 0.6745 * stat_alt_r.sigma
  475. # SEP(50) calculated per RCC 261-00, Section 3.1.3 (3)
  476. calc_sep = 0.51 * (stat_lat_r.sigma +
  477. stat_lon_r.sigma +
  478. stat_alt_r.sigma)
  479. # MRSE calculated per RCC 261-00, Section 3.1.5
  480. calc_mrse = math.sqrt(stat_lat_r.sigma ** 2 +
  481. stat_lon_r.sigma ** 2 +
  482. stat_alt_r.sigma ** 2)
  483. fmt_lab11a = ('hep = %.3f meters\\n'
  484. 'sep = %.3f meters\\n'
  485. 'mrse = %.3f meters\\n'
  486. ) % (calc_hep, calc_sep, calc_mrse)
  487. if self.centroid[0] < 0.0:
  488. latstring = "%.9fS" % -self.centroid[0]
  489. elif stat_lat.mean > 0.0:
  490. latstring = "%.9fN" % self.centroid[0]
  491. else:
  492. latstring = "0.0"
  493. if self.centroid[1] < 0.0:
  494. lonstring = "%.9fW" % -self.centroid[1]
  495. elif stat_lon.mean > 0.0:
  496. lonstring = "%.9fE" % self.centroid[1]
  497. else:
  498. lonstring = "0.0"
  499. # oh, this is fun, mixing gnuplot and python string formatting
  500. # Grrr, python implements %s max width or precision incorrectly...
  501. # and the old and new styles also disagree...
  502. fmt = ('set xlabel "Meters east from %s"\n'
  503. 'set ylabel "Meters north from %s"\n'
  504. 'cep=%.9f\n'
  505. 'cep95=%.9f\n'
  506. 'cep99=%.9f\n'
  507. ) % (lonstring, latstring,
  508. cep_meters, cep95_meters, cep99_meters)
  509. fmt += ('set autoscale\n'
  510. 'set multiplot\n'
  511. # plot to use 95% of width
  512. # set x and y scales to same distance
  513. 'set size ratio -1 0.95,0.7\n'
  514. # leave room at bottom for computed variables
  515. 'set origin 0.025,0.30\n'
  516. 'set format x "%.3f"\n'
  517. 'set format y "%.3f"\n'
  518. 'set key left at screen 0.6,0.30 vertical\n'
  519. 'set key noautotitle\n'
  520. 'set style line 2 pt 1\n'
  521. 'set style line 3 pt 2\n'
  522. 'set style line 5 pt 7 ps 1\n'
  523. 'set xtic rotate by -45\n'
  524. 'set border 15\n'
  525. # now the CEP stuff
  526. 'set parametric\n'
  527. 'set trange [0:2*pi]\n'
  528. 'cx(t, r) = sin(t)*r\n'
  529. 'cy(t, r) = cos(t)*r\n'
  530. 'chlen = cep/20\n'
  531. # what do the next two lines do??
  532. 'set arrow from -chlen,0 to chlen,0 nohead\n'
  533. 'set arrow from 0,-chlen to 0,chlen nohead\n')
  534. fmt += ('set label 11 at screen 0.01, screen 0.30 '
  535. '"RCC 261-00\\n'
  536. 'cep = %.3f meters\\n'
  537. '2drms = %.3f meters\\n%s'
  538. '2d max = %.3f meters\\n'
  539. '3d max = %.3f meters"\n'
  540. ) % (calc_cep, calc_2drms, fmt_lab11a, cep100_meters,
  541. dist_3d_max)
  542. # row labels
  543. fmt += ('set label 12 at screen 0.01, screen 0.12 '
  544. '"RCC 261-00\\n'
  545. '\\n'
  546. 'Lat\\n'
  547. 'Lon\\n'
  548. 'AltHAE\\n'
  549. 'Used"\n')
  550. # mean
  551. fmt += ('set label 13 at screen 0.06, screen 0.12 '
  552. '"\\n'
  553. ' mean\\n'
  554. '%s\\n'
  555. '%s\\n'
  556. '%s\\n'
  557. '%s"\n'
  558. ) % ('{:>15}'.format(latstring),
  559. '{:>15}'.format(lonstring),
  560. '{:>15.3f}'.format(stat_alt.mean),
  561. '{:>15.1f}'.format(stat_used.mean))
  562. fmt += ('set label 14 at screen 0.23, screen 0.12 '
  563. '"\\n'
  564. ' min max sigma '
  565. 'skewness kurtosis\\n'
  566. '%s %s %s meters %s %s\\n'
  567. '%s %s %s meters %s %s\\n'
  568. '%s %s %s meters %s %s\\n'
  569. '%12d %12d %s sats"\n'
  570. ) % ('{:>10.3f}'.format(lat_min_o),
  571. '{:>10.3f}'.format(lat_max_o),
  572. '{:>10.3f}'.format(stat_lat_r.sigma),
  573. '{:>10.1f}'.format(stat_lat_r.skewness),
  574. '{:>10.1f}'.format(stat_lat_r.kurtosis),
  575. '{:>10.3f}'.format(lon_min_o),
  576. '{:>10.3f}'.format(lon_max_o),
  577. '{:>10.3f}'.format(stat_lon_r.sigma),
  578. '{:>10.1f}'.format(stat_lon_r.skewness),
  579. '{:>10.1f}'.format(stat_lon_r.kurtosis),
  580. '{:>10.3f}'.format(stat_alt_r.min),
  581. '{:>10.3f}'.format(stat_alt_r.max),
  582. '{:>10.3f}'.format(stat_alt_r.sigma),
  583. '{:>10.1f}'.format(stat_alt_r.skewness),
  584. '{:>10.1f}'.format(stat_alt_r.kurtosis),
  585. stat_used.min,
  586. stat_used.max,
  587. '{:>10.1f}'.format(stat_used.sigma))
  588. if debug:
  589. fmt += ('set label 15 at screen 0.6, screen 0.12 '
  590. '"\\n'
  591. ' min\\n'
  592. '%s\\n'
  593. '%s\\n'
  594. '%s"\n'
  595. ) % ('{:>15.9f}'.format(stat_lat_r.min),
  596. '{:>15.9f}'.format(stat_lon_r.min),
  597. '{:>15.3f}'.format(stat_alt.min))
  598. fmt += ('set label 16 at screen 0.75, screen 0.12 '
  599. '"\\n'
  600. ' max\\n'
  601. '%s\\n'
  602. '%s\\n'
  603. '%s"\n'
  604. ) % ('{:>15.9f}'.format(stat_lat_r.max),
  605. '{:>15.9f}'.format(stat_lon_r.max),
  606. '{:>15.3f}'.format(stat_alt.max))
  607. if len(self.fixes) > 1000:
  608. plot_style = 'dots'
  609. else:
  610. plot_style = 'points'
  611. # got altitude data?
  612. if not math.isnan(stat_alt.mean):
  613. fmt += ('set ytics nomirror\n'
  614. 'set y2tics\n'
  615. 'set format y2 "%.3f"\n')
  616. fmt += (('set y2label "AltHAE from %.3f meters"\n') %
  617. (stat_alt.mean))
  618. # add ep(50)s
  619. altitude_x = cep100_meters * 1.2
  620. fmt += ('$EPData << EOD\n'
  621. '%.3f %.3f\n'
  622. '%.3f %.3f\n'
  623. 'EOD\n'
  624. ) % (altitude_x, alt_ep,
  625. altitude_x, -alt_ep)
  626. fmt += ('$EP95Data << EOD\n'
  627. '%.3f %.3f\n'
  628. '%.3f %.3f\n'
  629. 'EOD\n'
  630. ) % (altitude_x, alt_ep95,
  631. altitude_x, -alt_ep95)
  632. fmt += ('$EP99Data << EOD\n'
  633. '%.3f %.3f\n'
  634. '%.3f %.3f\n'
  635. 'EOD\n'
  636. ) % (altitude_x, alt_ep99,
  637. altitude_x, -alt_ep99)
  638. # setup now done, plot it!
  639. fmt += ('plot "-" using 1:2 with %s ls 3 title "%d GPS fixes" '
  640. ', cx(t,cep),cy(t,cep) ls 1 title "CEP (50%%) = %.3f meters"'
  641. ', cx(t,cep95),cy(t,cep95) title "CEP (95%%) = %.3f meters"'
  642. ', cx(t,cep99),cy(t,cep99) title "CEP (99%%) = %.3f meters"'
  643. ) % (plot_style, len(self.fixes),
  644. cep_meters, cep95_meters, cep99_meters)
  645. if not math.isnan(stat_alt.mean):
  646. # add plot of altitude
  647. fmt += (', "-" using ( %.3f ):( $1 - %.3f ) '
  648. 'axes x1y2 with points ls 2 lc "green"'
  649. ' title " %d AltHAE fixes"'
  650. ) % (cep100_meters * 1.1, stat_alt.mean, len(alt_fixes))
  651. # altitude EPs
  652. fmt += (', $EPData using 1:2 '
  653. 'axes x1y2 with points ls 5 lc "dark-green"'
  654. ' title " EP(50%%) = %.3f meters"'
  655. ) % (alt_ep)
  656. fmt += (', $EP95Data using 1:2 '
  657. 'axes x1y2 with points ls 5 lc "blue"'
  658. ' title " EP(95%%) = %.3f meters"'
  659. ) % (alt_ep95)
  660. fmt += (', $EP99Data using 1:2 '
  661. 'axes x1y2 with points ls 5 lc "red"'
  662. ' title " EP(99%%) = %.3f meters"'
  663. ) % (alt_ep99)
  664. fmt += self.header() + latlon_data
  665. if not math.isnan(stat_alt.mean):
  666. # add altitude samples
  667. fmt += 'e\n' + alt_data
  668. return fmt
  669. class polarplot(plotter):
  670. "Polar plot of signal strength"
  671. name = "polar"
  672. requires_time = False
  673. seen_used = [] # count of seen and used in each SKY
  674. def __init__(self):
  675. plotter.__init__(self)
  676. self.watch = set(['SKY'])
  677. def sample(self):
  678. "Grab samples"
  679. if self.session.data["class"] == "SKY":
  680. sats = self.session.data['satellites']
  681. seen = 0
  682. used = 0
  683. for sat in sats:
  684. seen += 1
  685. # u'ss': 42, u'el': 15, u'PRN': 18, u'az': 80, u'used': True
  686. if ((('az' not in sat) or
  687. ('el' not in sat))):
  688. continue
  689. if sat['used'] is True:
  690. used += 1
  691. if 'polarunused' == self.name:
  692. continue
  693. if (('polarused' == self.name) and (sat['used'] is False)):
  694. continue
  695. self.fixes.append((sat['PRN'], sat['ss'], sat['az'],
  696. sat['el'], sat['used']))
  697. self.seen_used.append((seen, used))
  698. return True
  699. def header(self):
  700. "Return header"
  701. return "# Polar plot of signal strengths, %s\n" % self.whatami()
  702. def postprocess(self):
  703. "Postprocess the sample data"
  704. return
  705. def data(self):
  706. "Format data for dump"
  707. res = ""
  708. for (prn, ss, az, el, used) in self.fixes:
  709. res += "%d\t%d\t%d\t%d\t%s\n" % (prn, ss, az, el, used)
  710. return res
  711. def plot(self):
  712. "Format data for dump"
  713. # calc SNR: mean, min, max, sigma
  714. stat_ss = stats()
  715. stat_ss.min_max_mean(self.fixes, 1)
  716. stat_ss.moments(self.fixes, 1)
  717. # calc sats seen data: mean, min, max, sigma
  718. stat_seen = stats()
  719. stat_seen.min_max_mean(self.seen_used, 0)
  720. stat_seen.moments(self.seen_used, 0)
  721. # calc sats used data: mean, min, max, sigma
  722. stat_used = stats()
  723. stat_used.min_max_mean(self.seen_used, 1)
  724. stat_used.moments(self.seen_used, 1)
  725. fmt = '''\
  726. unset border
  727. set polar
  728. set angles degrees # set gnuplot on degrees instead of radians
  729. set style line 10 lt 1 lc 0 lw 0.3 #redefine a new line style for the grid
  730. set grid polar 45 #set the grid to be displayed every 45 degrees
  731. set grid ls 10
  732. # x is angle, go from 0 to 360 degrees
  733. # y is radius, go from 90 at middle to 0 at edge
  734. set xrange [0:360]
  735. set rrange [90:0] # 90 at center
  736. set yrange [-90:90]
  737. # set xtics axis #display the xtics on the axis instead of on the border
  738. # set ytics axis
  739. set xtics axis nomirror; set ytics axis nomirror
  740. # "remove" the tics so that only the y tics are displayed
  741. set xtics scale 0
  742. # set the xtics only go from 0 to 90 with increment of 30
  743. # but do not display anything. This has to be done otherwise the grid
  744. # will not be displayed correctly.
  745. set xtics ("" 90, "" 60, "" 30,)
  746. # make the ytics go from the center (0) to 360 with increment of 90
  747. # set ytics 0, 45, 360
  748. set ytics scale 0
  749. # set the ytics only go from 0 to 90 with increment of 30
  750. # but do not display anything. This has to be done otherwise the grid
  751. # will not be displayed correctly.
  752. set ytics ("" 90, "" 60, "" 30,)
  753. set size square
  754. set key lmargin
  755. # this places a compass label on the outside
  756. set_label(x, text) = sprintf(
  757. "set label '%s' at (93*cos(%f)), (93*sin(%f)) center", text, x, x)
  758. # here all labels are created
  759. # we compute North (0) at top, East (90) at right
  760. # bug gnuplot puts 0 at right, 90 at top
  761. eval set_label(0, "E")
  762. eval set_label(90, "N")
  763. eval set_label(180, "W")
  764. eval set_label(270, "S")
  765. set style line 11 pt 2 ps 2 #set the line style for the plot
  766. set style fill transparent solid 0.8 noborder
  767. # set rmargin then put colorbox in the margin.
  768. set lmargin at screen .08
  769. set rmargin at screen .85
  770. set cbrange [10:60]
  771. set palette defined (100 "blue", 200 "green", 300 "red")
  772. set colorbox user origin .92, .15 size .03, .6
  773. set label 10 at screen 0.89, screen 0.13 "SNR dBHz"
  774. '''
  775. count = len(self.fixes)
  776. fmt += '''\
  777. set label 11 at screen 0.01, screen 0.15 "%s plot, samples %d"
  778. set label 12 at screen 0.01, screen 0.10 "\\nSS\\nSeen\\nUsed"
  779. ''' % (self.name, count)
  780. fmt += '''\
  781. set label 13 at screen 0.11, screen 0.10 "min\\n%d\\n%d\\n%d" right
  782. ''' % (stat_ss.min, stat_seen.min, stat_used.min)
  783. fmt += '''\
  784. set label 14 at screen 0.21, screen 0.10 "max\\n%d\\n%d\\n%d" right
  785. ''' % (stat_ss.max, stat_seen.max, stat_used.max)
  786. fmt += '''\
  787. set label 15 at screen 0.31, screen 0.10 "mean\\n%.1f\\n%.1f\\n%.1f" right
  788. ''' % (stat_ss.mean, stat_seen.mean, stat_used.mean)
  789. fmt += '''\
  790. set label 16 at screen 0.41, screen 0.10 "sigma\\n%.1f\\n%.1f\\n%.1f" right
  791. ''' % (stat_ss.sigma, stat_seen.sigma, stat_used.sigma)
  792. fmt += '''\
  793. # and finally the plot
  794. # flip azimuth to plot north up, east right
  795. # plot "-" u (90 - $3):4 t "Sat" with points ls 11
  796. plot "-" u (90 - $3):4:(1):($2) t "Sat" w circles lc palette
  797. '''
  798. # return fmt + self.header() + self.data()
  799. return self.header() + fmt + self.data()
  800. class polarplotunused(polarplot):
  801. "Polar plot of unused sats signal strength"
  802. name = "polarunused"
  803. class polarplotused(polarplot):
  804. "Polar plot of used sats signal strength"
  805. name = "polarused"
  806. class timeplot(plotter):
  807. "Time drift against PPS."
  808. name = "time"
  809. requires_time = True
  810. def __init__(self):
  811. plotter.__init__(self)
  812. self.watch = set(['PPS'])
  813. def sample(self):
  814. "Grab samples"
  815. if self.session.data["class"] == "PPS":
  816. self.fixes.append((self.session.data['real_sec'],
  817. self.session.data['real_nsec'],
  818. self.session.data['clock_sec'],
  819. self.session.data['clock_nsec']))
  820. return True
  821. def header(self):
  822. "Return header"
  823. return "# Time drift against PPS, %s\n" % self.whatami()
  824. def postprocess(self):
  825. "Postprocess the sample data"
  826. return
  827. def data(self):
  828. "Format data for dump"
  829. res = ""
  830. for (real_sec, real_nsec, clock_sec, clock_nsec) in self.fixes:
  831. res += "%d\t%d\t%d\t%d\n" % (real_sec, real_nsec, clock_sec,
  832. clock_nsec)
  833. return res
  834. def plot(self):
  835. "Format data for dump"
  836. fmt = '''\
  837. set autoscale
  838. set key below
  839. set ylabel "System clock delta from GPS time (nsec)"
  840. plot "-" using 0:((column(1)-column(3))*1e9 + (column(2)-column(4))) \
  841. title "Delta" with impulses
  842. '''
  843. return fmt + self.header() + self.data()
  844. class uninstrumented(plotter):
  845. "Total times without instrumentation."
  846. name = "uninstrumented"
  847. requires_time = False
  848. def __init__(self):
  849. plotter.__init__(self)
  850. def sample(self):
  851. "Grab samples"
  852. if self.session.fix.time:
  853. seconds = time.time() - gps.misc.isotime(self.session.data.time)
  854. self.fixes.append(seconds)
  855. return True
  856. return False
  857. def header(self):
  858. "Return header"
  859. return "# Uninstrumented total latency, " + self.whatami() + "\n"
  860. def postprocess(self):
  861. "Postprocess the sample data"
  862. return
  863. def data(self):
  864. "Format data for dump"
  865. res = ""
  866. for seconds in self.fixes:
  867. res += "%2.6lf\n" % seconds
  868. return res
  869. def plot(self):
  870. "Plot the data"
  871. fmt = '''\
  872. set autoscale
  873. set key below
  874. set key title "Uninstrumented total latency"
  875. plot "-" using 0:1 title "Total time" with impulses
  876. '''
  877. return fmt + self.header() + self.data()
  878. class instrumented(plotter):
  879. "Latency as analyzed by instrumentation."
  880. name = "instrumented"
  881. requires_time = True
  882. def __init__(self):
  883. "Initialize class instrumented()"
  884. plotter.__init__(self)
  885. def sample(self):
  886. "Grab the samples"
  887. if 'rtime' in self.session.data:
  888. self.fixes.append((gps.misc.isotime(self.session.data['time']),
  889. self.session.data["chars"],
  890. self.session.data['sats'],
  891. self.session.data['sor'],
  892. self.session.data['rtime'],
  893. time.time()))
  894. return True
  895. return False
  896. def header(self):
  897. "Return the header"
  898. res = "# Analyzed latency, " + self.whatami() + "\n"
  899. res += "#-- Fix time -- - Chars - -- Latency - RS232- " \
  900. "Analysis - Recv -\n"
  901. return res
  902. def postprocess(self):
  903. "Postprocess the sample data"
  904. return
  905. def data(self):
  906. "Format data for dump"
  907. res = ""
  908. for (fix_time, chars, sats, start, xmit, recv) in self.fixes:
  909. rs232_time = (chars * 10.0) / self.device['bps']
  910. res += "%.3f %9u %2u %.6f %.6f %.6f %.6f\n" \
  911. % (fix_time, chars, sats, start - fix_time,
  912. (start - fix_time) + rs232_time, xmit - fix_time,
  913. recv - fix_time)
  914. return res
  915. def plot(self):
  916. "Do the plot"
  917. legends = (
  918. "Reception delta",
  919. "Analysis time",
  920. "RS232 time",
  921. "Fix latency",
  922. )
  923. fmt = '''\
  924. set autoscale
  925. set key title "Analyzed latency"
  926. set key below
  927. plot \\\n'''
  928. for (i, legend) in enumerate(legends):
  929. j = len(legends) - i + 4
  930. fmt += ' "-" using 0:%d title "%s" with impulses, \\\n' \
  931. % (j, legend)
  932. fmt = fmt[:-4] + "\n"
  933. return fmt + self.header() + (self.data() + "e\n") * len(legends)
  934. formatters = (polarplot, polarplotunused, polarplotused, spaceplot, timeplot,
  935. uninstrumented, instrumented)
  936. def usage():
  937. "Print help, then exit"
  938. sys.stderr.write('''\
  939. usage: gpsprof [OPTION]... [server[:port[:device]]]
  940. [-D debuglevel]
  941. [-d dumpfile]
  942. [-f {%s}]
  943. [-h]
  944. [-l logfile]
  945. [-m threshold]
  946. [-n samplecount]
  947. [-r]
  948. [-s speed]
  949. [-S subtitle]
  950. [-T terminal]
  951. [-t title]
  952. [-V]
  953. ''' % ("|".join([x.name for x in formatters])))
  954. sys.exit(0)
  955. if __name__ == '__main__':
  956. try:
  957. (options, arguments) = getopt.getopt(sys.argv[1:],
  958. "d:f:hl:m:n:rs:t:D:S:T:V")
  959. plotmode = "space"
  960. raw = False
  961. title = None
  962. subtitle = None
  963. threshold = 0
  964. wait = 100
  965. verbose = 0
  966. terminal = None
  967. dumpfile = None
  968. logfp = None
  969. redo = False
  970. for (switch, val) in options:
  971. if switch == '-d':
  972. dumpfile = val
  973. elif switch == '-D':
  974. # set debug level, 0 off, 1 medium, 2 high
  975. verbose = int(val)
  976. elif switch == '-f':
  977. plotmode = val
  978. elif switch == '-h':
  979. # print help, then exit
  980. usage()
  981. elif switch == '-l':
  982. logfp = open(val, "w")
  983. elif switch == '-m':
  984. threshold = int(val)
  985. elif switch == '-n':
  986. if val[-1] == 'h':
  987. wait = int(val[:-1]) * 360
  988. else:
  989. wait = int(val)
  990. elif switch == '-r':
  991. redo = True
  992. elif switch == '-t':
  993. # replace title
  994. title = val
  995. elif switch == '-S':
  996. # add sub title
  997. subtitle = val
  998. elif switch == '-T':
  999. terminal = val
  1000. elif switch == '-V':
  1001. sys.stderr.write("gpsprof: Version %s\n" % gps_version)
  1002. sys.exit(0)
  1003. (host, port, device) = ("localhost", "2947", None)
  1004. if arguments:
  1005. args = arguments[0].split(":")
  1006. if len(args) >= 1:
  1007. host = args[0]
  1008. if len(args) >= 2:
  1009. port = args[1]
  1010. if len(args) >= 3:
  1011. device = args[2]
  1012. # Select the plotting mode
  1013. if plotmode:
  1014. for formatter in formatters:
  1015. if formatter.name == plotmode:
  1016. plot = formatter()
  1017. break
  1018. else:
  1019. sys.stderr.write("gpsprof: no such formatter.\n")
  1020. sys.exit(1)
  1021. # Get fix data from the GPS
  1022. if redo:
  1023. plot.replot(sys.stdin)
  1024. else:
  1025. plot.collect(verbose, logfp)
  1026. plot.postprocess()
  1027. # Save the timing data (only) for post-analysis if required.
  1028. if dumpfile:
  1029. with open(dumpfile, "w") as fp:
  1030. fp.write(plot.dump())
  1031. if logfp:
  1032. logfp.close()
  1033. # Ship the plot to standard output
  1034. if not title:
  1035. title = plot.whatami()
  1036. # escape " for gnuplot
  1037. title = title.replace('"', '\\"')
  1038. if subtitle:
  1039. title += '\\n' + subtitle
  1040. if terminal:
  1041. sys.stdout.write("set terminal %s truecolor enhanced size"
  1042. " 800,950\n"
  1043. % terminal)
  1044. # double quotes on title so \n is parsed by gnuplot
  1045. sys.stdout.write('set title noenhanced "%s\\n\\n"\n' % title)
  1046. sys.stdout.write(plot.plot())
  1047. except getopt.GetoptError as error:
  1048. print(error)
  1049. print("")
  1050. usage()
  1051. sys.exit(1)
  1052. except KeyboardInterrupt:
  1053. pass
  1054. # The following sets edit modes for GNU EMACS
  1055. # Local Variables:
  1056. # mode:python
  1057. # End: