cycle_analyzer 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315
  1. #!/usr/bin/env python3
  2. #
  3. # This file is Copyright 2010 by the GPSD project
  4. # SPDX-License-Identifier: BSD-2-clause
  5. """
  6. cycle_analyzer - perform cycle analysis on GPS log files
  7. This tool analyzes one or more NMEA or JSON files to determine the
  8. cycle sequence of sentences. JSON files must be reports from a gpsd
  9. driver which is without the CYCLE_END_RELIABLE capability and
  10. therefore ships every sentence containing a fix; otherwise the results
  11. will be meaningless because only the end-of-cycle sentences will show
  12. in the JSON.
  13. If a filename argument ends with '.log', and the sentence type in it
  14. is not recognizable, this tool adds '.chk' to the name and tries again
  15. assuming the latter is a JSON dump. Thus, invoking it again *.log in
  16. a directory full of check files will do the right thing.
  17. One purpose of this tool is to determine the end-of-cycle sentence
  18. that a binary-protocol device emits, so the result can be patched into
  19. the driver as a CYCLE_END_RELIABLE capability. To get this, apply the
  20. tool to the JSON output from the driver using the -j switch. It will
  21. ignore everything but tag and timestamp fields, and will also ignore
  22. any NMEA in the file.
  23. Another purpose is to sanity-check the assumptions of the NMEA
  24. end-of-cycle detector. For this purpose, run without -j; if a device
  25. has a regular reporting cycle with a constant end-of-cycle sentence,
  26. this tool will confirm that. Otherwise, it will perform various
  27. checks attempting to find an end-of-cycle marker and report on what it
  28. finds.
  29. When cycle_analyzer reports a split- or variable-cycle device, some arguments
  30. to the -d switch can dump various analysis stages so you can get a better
  31. idea what is going on. These are:
  32. sequence - the entire sequence of dump tag/timestamp pairs from the log
  33. events - show how those reduce to event sequences
  34. bursts - show how sentences are grouped into bursts
  35. trim - show the burst list after the end bursts have been removed
  36. In an event sequence, a '<' is a nornal start of cycle where the
  37. timestamp increments. A '>' is where the timestamp actually
  38. *decreases* between a a sentence and the one that follows. The NMEA
  39. cycle-end detector will ignore the '>' event; it sometimes occurs when
  40. GPZDA starts a cycle, but has no effect on where the actual end of
  41. fix reporting is.
  42. If you see a message saying 'cycle-enders ... also occur in mid-cycle', the
  43. device will confuse the NMEA cycle detector, leading to more reports per cycle
  44. than the ideal.
  45. """
  46. # This code runs compatibly under Python 2 and 3.x for x >= 2.
  47. # Preserve this property!
  48. from __future__ import absolute_import, print_function, division
  49. import getopt
  50. import gps
  51. import json
  52. import os
  53. import sys
  54. verbose = 0
  55. suppress_regular = False
  56. parse_json = False
  57. class analyze_error(BaseException):
  58. def __init__(self, filename, msg):
  59. self.filename = filename
  60. self.msg = msg
  61. def __repr__(self):
  62. return '%s: %s' % (self.filename, self.msg)
  63. class event(object):
  64. def __init__(self, tag, time=0):
  65. self.tag = tag
  66. self.time = time
  67. def __str__(self):
  68. if self.time == 0:
  69. return self.tag
  70. else:
  71. return self.tag + ":" + self.time
  72. __repr__ = __str__
  73. def tags(lst):
  74. return [x.tag for x in lst]
  75. def extract_from_nmea(filename, lineno, line):
  76. "Extend sequence of tag/timestamp tuples from an NMEA sentence"
  77. hhmmss = {
  78. "RMC": 1,
  79. "GLL": 5,
  80. "GGA": 1,
  81. "GBS": 1,
  82. "PASHR": {"POS": 4},
  83. }
  84. fields = line.split(",")
  85. tag = fields[0]
  86. if tag.startswith("$GP") or tag.startswith("$IN"):
  87. tag = tag[3:]
  88. elif tag[0] == "$":
  89. tag = tag[1:]
  90. field = hhmmss.get(tag)
  91. if isinstance(field, dict):
  92. field = field.get(fields[1])
  93. if field:
  94. timestamp = fields[field]
  95. return [event(tag, timestamp)]
  96. else:
  97. return []
  98. def extract_from_json(filename, lineno, line):
  99. "Extend sequence of tag/timestamp tuples from a JSON dump of a sentence"
  100. if not line.startswith("{"):
  101. return []
  102. try:
  103. sentence = json.loads(line)
  104. if "time" not in sentence:
  105. return []
  106. return [event(gps.polystr(sentence["class"]), "%.2f" %
  107. gps.isotime(sentence["time"]))]
  108. except ValueError as e:
  109. print(line.rstrip(), file=sys.stderr)
  110. print(repr(e), file=sys.stderr)
  111. return []
  112. def extract_timestamped_sentences(fp, json_parse=parse_json):
  113. "Do the basic work of extracting tags and timestamps"
  114. sequence = []
  115. lineno = 0
  116. while True:
  117. line = gps.polystr(fp.readline())
  118. if not line:
  119. break
  120. lineno += 1
  121. if line.startswith("#"):
  122. continue
  123. if line[0] not in ("$", "!", "{"):
  124. raise analyze_error(fp.name, "unknown sentence type.")
  125. if not json_parse and line.startswith("$"):
  126. sequence += extract_from_nmea(fp.name, lineno, line)
  127. elif json_parse and line.startswith("{"):
  128. sequence += extract_from_json(fp.name, lineno, line)
  129. return sequence
  130. def analyze(sequence, name):
  131. "Analyze the cycle sequence of a device from its output logs."
  132. # First, extract tags and timestamps
  133. regular = False
  134. if not sequence:
  135. return
  136. if "sequence" in stages:
  137. print("Raw tag/timestamp sequence")
  138. for e in sequence:
  139. print(e)
  140. # Then, do cycle detection
  141. events = []
  142. out_of_order = False
  143. for i in range(len(sequence)):
  144. this = sequence[i]
  145. if this.time == "" or float(this.time) == 0:
  146. continue
  147. events.append(this)
  148. if i < len(sequence)-1:
  149. next = sequence[i+1]
  150. if float(this.time) < float(next.time):
  151. events.append(event("<"))
  152. if float(this.time) > float(next.time):
  153. events.append(event(">"))
  154. out_of_order = True
  155. if out_of_order and verbose:
  156. sys.stderr.write("%s: has some timestamps out of order.\n" % name)
  157. if "events" in stages:
  158. print("Event list:")
  159. for e in events:
  160. print(e)
  161. # Now group events into bursts
  162. bursts = []
  163. current = []
  164. for e in events + [event('<')]:
  165. if e.tag == '<':
  166. bursts.append(tuple(current))
  167. current = []
  168. else:
  169. current.append(e)
  170. if "bursts" in stages:
  171. print("Burst list:")
  172. for burst in bursts:
  173. print(burst)
  174. # We need 4 cycles because the first and last might be incomplete.
  175. if tags(events).count("<") < 4:
  176. sys.stderr.write("%s: has fewer than 4 cycles.\n" % name)
  177. return
  178. # First try at detecting a regular cycle
  179. unequal = False
  180. for i in range(len(bursts)-1):
  181. if tags(bursts[i]) != tags(bursts[i+1]):
  182. unequal = True
  183. break
  184. if not unequal:
  185. # All bursts looked the same
  186. regular = True
  187. else:
  188. # Trim off first and last bursts, which are likely incomplete.
  189. bursts = bursts[1:-1]
  190. if "trim" in stages:
  191. "After trimming:"
  192. for burst in bursts:
  193. print(burst)
  194. # Now the actual clique analysis
  195. unequal = False
  196. for i in range(len(bursts)-1):
  197. if tags(bursts[i]) != tags(bursts[i+1]):
  198. unequal = True
  199. break
  200. if not unequal:
  201. regular = True
  202. # Should know now if cycle is regular
  203. if regular:
  204. if not suppress_regular:
  205. print("%s: has a regular cycle %s." %
  206. (name, " ".join(tags(bursts[0]))))
  207. else:
  208. # If it was not the case that all cycles matched, then we need
  209. # a minimum of 6 cycles because the first and last might be
  210. # incomplete, and we need at least 4 cycles in the middle to
  211. # have two full ones on split-cycle devices like old Garmins.
  212. if tags(events).count("<") < 6:
  213. sys.stderr.write("%s: variable-cycle log has has fewer "
  214. "than 6 cycles.\n" % name)
  215. return
  216. if verbose > 0:
  217. print("%s: has a split or variable cycle." % name)
  218. cycle_enders = []
  219. for burst in bursts:
  220. if burst[-1].tag not in cycle_enders:
  221. cycle_enders.append(burst[-1].tag)
  222. if len(cycle_enders) == 1:
  223. if not suppress_regular:
  224. print("%s: has a fixed end-of-cycle sentence %s." %
  225. (name, cycle_enders[0]))
  226. else:
  227. print("%s: has multiple cycle-enders %s." %
  228. (name, " ".join(cycle_enders)))
  229. # Sanity check
  230. pathological = []
  231. for ender in cycle_enders:
  232. for burst in bursts:
  233. if ((ender in tags(burst) and
  234. not ender == burst[-1].tag and
  235. ender not in pathological)):
  236. pathological.append(ender)
  237. if pathological:
  238. print("%s: cycle-enders %s also occur in mid-cycle!" %
  239. (name, " ".join(pathological)))
  240. if __name__ == "__main__":
  241. stages = ""
  242. try:
  243. (options, arguments) = getopt.getopt(sys.argv[1:], "d:jsv")
  244. for (switch, val) in options:
  245. if (switch == '-d'): # Debug
  246. stages = val
  247. elif (switch == '-j'): # Interpret JSON, not NMEA
  248. parse_json = True
  249. elif (switch == '-v'): # Verbose
  250. verbose += 1
  251. elif (switch == '-s'): # Suppress logs with no problems
  252. suppress_regular = True
  253. except getopt.GetoptError as msg:
  254. print("cycle_analyzer: " + str(msg))
  255. raise SystemExit(1)
  256. try:
  257. if arguments:
  258. for filename in arguments:
  259. fp = open(filename, 'rb')
  260. try:
  261. sequence = extract_timestamped_sentences(fp)
  262. analyze(sequence, filename)
  263. except analyze_error as e:
  264. if filename.endswith(".log") and os.path.exists(filename +
  265. ".chk"):
  266. fp2 = open(filename+".chk", "rb")
  267. try:
  268. sequence = extract_timestamped_sentences(
  269. fp2,
  270. json_parse=True)
  271. analyze(sequence, filename+".chk")
  272. finally:
  273. fp2.close()
  274. else:
  275. print(repr(e), file=sys.stderr)
  276. fp.close()
  277. else:
  278. sequence = extract_timestamped_sentences(sys.stdin)
  279. analyze(sequence, "standard input")
  280. except analyze_error as e:
  281. print(repr(e), file=sys.stderr)
  282. raise SystemExit(1)
  283. # vim: set expandtab shiftwidth=4