skyview2svg.py.in 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371
  1. #!@PYSHEBANG@
  2. # -*- coding: utf-8 -*-
  3. # @GENERATED@
  4. """
  5. skyview2svg -- Create an SVG image of GPS satellites sky view.
  6. Read from file or stdin the JSON data produced by gpsd,
  7. example usage:
  8. gpspipe -w | skyview2svg > skyview.svg
  9. For GPSD JSON format see: https://gpsd.gitlab.io/gpsd/gpsd_json.html
  10. """
  11. # This code runs compatibly under Python 2 and 3.x for x >= 2.
  12. # Preserve this property!
  13. #
  14. # This file is Copyright 2010
  15. # SPDX-License-Identifier: BSD-2-clause
  16. from __future__ import absolute_import, print_function, division
  17. import datetime
  18. import json
  19. import math
  20. import sys
  21. __author__ = "Niccolo Rigacci"
  22. __copyright__ = "Copyright 2018 Niccolo Rigacci <niccolo@rigacci.org>"
  23. __license__ = "BSD-2-clause"
  24. __email__ = "niccolo@rigacci.org"
  25. __version__ = "@VERSION@"
  26. # ------------------------------------------------------------------------
  27. # ------------------------------------------------------------------------
  28. def polar2cart(azimuth, elevation, r_max):
  29. """Convert polar coordinates in cartesian ones."""
  30. radius = r_max * (1 - elevation / 90.0)
  31. theta = math.radians(float(azimuth - 90))
  32. return (
  33. int(radius * math.cos(theta) + 0.5),
  34. int(radius * math.sin(theta) + 0.5)
  35. )
  36. # ------------------------------------------------------------------------
  37. # ------------------------------------------------------------------------
  38. def cutoff_err(err, err_min, err_max):
  39. """Cut-off Estimated Error between min and max."""
  40. if err is None or err >= err_max:
  41. return err_max, '&gt;'
  42. if err <= err_min:
  43. return err_min, '&lt;'
  44. else:
  45. return err, ''
  46. # ------------------------------------------------------------------------
  47. # Read JSON data from file or stdin, search a {'class': 'SKY'} line.
  48. # ------------------------------------------------------------------------
  49. EXIT_CODE = 0
  50. SKY = None
  51. TPV = None
  52. try:
  53. if len(sys.argv) > 1:
  54. with open(sys.argv[1]) as f:
  55. while True:
  56. SENTENCE = json.loads(f.readline())
  57. if 'class' in SENTENCE and SENTENCE['class'] == 'SKY':
  58. SKY = SENTENCE
  59. if 'class' in SENTENCE and SENTENCE['class'] == 'TPV':
  60. TPV = SENTENCE
  61. if SKY is not None and TPV is not None:
  62. break
  63. else:
  64. while True:
  65. SENTENCE = json.loads(sys.stdin.readline())
  66. if 'class' in SENTENCE and SENTENCE['class'] == 'SKY':
  67. SKY = SENTENCE
  68. if 'class' in SENTENCE and SENTENCE['class'] == 'TPV':
  69. TPV = SENTENCE
  70. if SKY is not None and TPV is not None:
  71. sys.stdin.close()
  72. break
  73. except (IOError, ValueError):
  74. # Assume empty data and write msg to stderr.
  75. EXIT_CODE = 100
  76. sys.stderr.write("Error reading JSON data from file or stdin."
  77. " Creating an empty or partial skyview image.\n")
  78. if SKY is None:
  79. SKY = {}
  80. if TPV is None:
  81. TPV = {}
  82. # ------------------------------------------------------------------------
  83. # Colors for the SVG styles.
  84. # ------------------------------------------------------------------------
  85. # Background and label colors.
  86. BACKGROUND_COLOR = '#323232'
  87. LBL_FONT_COLOR = 'white'
  88. FONT_FAMILY = 'Verdana,Arial,Helvetica,sans-serif'
  89. # Compass dial.
  90. COMPASS_STROKE_COLOR = '#9d9d9d'
  91. DIAL_POINT_COLOR = COMPASS_STROKE_COLOR
  92. # Satellites constellation.
  93. SAT_USED_FILL_COLOR = '#00ff00'
  94. SAT_UNUSED_FILL_COLOR = '#d0d0d0'
  95. SAT_USED_STROKE_COLOR = '#0b400b'
  96. SAT_UNUSED_STROKE_COLOR = '#101010'
  97. SAT_USED_TEXT_COLOR = '#000000'
  98. SAT_UNUSED_TEXT_COLOR = '#000000'
  99. # Sat signal/noise ratio box and bars.
  100. BARS_AREA_FILL_COLOR = '#646464'
  101. BARS_AREA_STROKE_COLOR = COMPASS_STROKE_COLOR
  102. BAR_USED_FILL_COLOR = '#00ff00'
  103. BAR_UNUSED_FILL_COLOR = '#ffffff'
  104. BAR_USED_STROKE_COLOR = '#324832'
  105. BAR_UNUSED_STROKE_COLOR = BACKGROUND_COLOR
  106. # ------------------------------------------------------------------------
  107. # Size and position of elements.
  108. # ------------------------------------------------------------------------
  109. IMG_WIDTH = 528
  110. IMG_HEIGHT = 800
  111. STROKE_WIDTH = int(IMG_WIDTH * 0.007)
  112. # Scale graph bars to accommodate at least MIN_SAT values.
  113. MIN_SAT = 12
  114. NUM_SAT = MIN_SAT
  115. # Auto-scale: reasonable values for Signal/Noise Ratio and Error.
  116. SNR_MAX = 30.0 # Do not autoscale below this value.
  117. # Auto-scale horizontal and vertical error, in meters.
  118. ERR_MIN = 5.0
  119. ERR_MAX = 75.0
  120. # Create an empty list, if satellites list is missing.
  121. if 'satellites' not in SKY.keys():
  122. SKY['satellites'] = []
  123. if len(SKY['satellites']) < MIN_SAT:
  124. NUM_SAT = MIN_SAT
  125. else:
  126. NUM_SAT = len(SKY['satellites'])
  127. # Make a sortable array and autoscale SNR.
  128. SATELLITES = {}
  129. for sat in SKY['satellites']:
  130. SATELLITES[sat['PRN']] = sat
  131. if float(sat['ss']) > SNR_MAX:
  132. SNR_MAX = float(sat['ss'])
  133. # Compass dial and satellites placeholders.
  134. CIRCLE_X = int(IMG_WIDTH * 0.50)
  135. CIRCLE_Y = int(IMG_WIDTH * 0.49)
  136. CIRCLE_R = int(IMG_HEIGHT * 0.22)
  137. SAT_WIDTH = int(CIRCLE_R * 0.24)
  138. SAT_HEIGHT = int(CIRCLE_R * 0.14)
  139. # GPS position.
  140. POS_LBL_X = int(IMG_WIDTH * 0.50)
  141. POS_LBL_Y = int(IMG_HEIGHT * 0.62)
  142. # Sat signal/noise ratio box and bars.
  143. BARS_BOX_WIDTH = int(IMG_WIDTH * 0.82)
  144. BARS_BOX_HEIGHT = int(IMG_HEIGHT * 0.14)
  145. BARS_BOX_X = int((IMG_WIDTH - BARS_BOX_WIDTH) * 0.5)
  146. BARS_BOX_Y = int(IMG_HEIGHT * 0.78)
  147. BAR_HEIGHT_MAX = int(BARS_BOX_HEIGHT * 0.72)
  148. BAR_SPACE = int((BARS_BOX_WIDTH - STROKE_WIDTH) / NUM_SAT)
  149. BAR_WIDTH = int(BAR_SPACE * 0.70)
  150. BAR_RADIUS = int(BAR_WIDTH * 0.20)
  151. # Error box and bars.
  152. ERR_BOX_X = int(IMG_WIDTH * 0.65)
  153. ERR_BOX_Y = int(IMG_HEIGHT * 0.94)
  154. ERR_BOX_WIDTH = int((BARS_BOX_X + BARS_BOX_WIDTH) - ERR_BOX_X)
  155. ERR_BOX_HEIGHT = BAR_SPACE * 2
  156. ERR_BAR_HEIGHT_MAX = int(ERR_BOX_WIDTH - STROKE_WIDTH*2)
  157. # Timestamp
  158. TIMESTAMP_X = int(IMG_WIDTH * 0.50)
  159. TIMESTAMP_Y = int(IMG_HEIGHT * 0.98)
  160. # Text labels.
  161. LBL_FONT_SIZE = int(IMG_WIDTH * 0.036)
  162. LBL_COMPASS_POINTS_SIZE = int(CIRCLE_R * 0.12)
  163. LBL_SAT_SIZE = int(SAT_HEIGHT * 0.75)
  164. LBL_SAT_BAR_SIZE = int(BAR_WIDTH * 0.90)
  165. # Get timestamp from GPS or system.
  166. if 'time' in SKY:
  167. UTC = datetime.datetime.strptime(SKY['time'], '%Y-%m-%dT%H:%M:%S.%fZ')
  168. elif 'time' in TPV:
  169. UTC = datetime.datetime.strptime(TPV['time'], '%Y-%m-%dT%H:%M:%S.%fZ')
  170. else:
  171. UTC = datetime.datetime.utcnow()
  172. TIME_STR = UTC.strftime('%Y-%m-%d %H:%M:%S UTC')
  173. # ------------------------------------------------------------------------
  174. # Output the SGV image.
  175. # ------------------------------------------------------------------------
  176. print('''<?xml version="1.0" encoding="UTF-8" ?>
  177. <!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN"
  178. "https://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
  179. <svg
  180. xmlns="https://www.w3.org/2000/svg"
  181. width="%d"
  182. height="%d">''' % (IMG_WIDTH, IMG_HEIGHT))
  183. # NOTICE: librsvg v.2.40 has a bug with "chain" multiple class selectors:
  184. # it does not handle a selector like text.label.title and a
  185. # tag class="label title".
  186. print('<style type="text/css">')
  187. # Labels.
  188. print(' text '
  189. '{ font-family: Verdana,Arial,Helvetica,sans-serif; font-weight: bold;}')
  190. print(' text.label { fill: %s; font-size: %dpx; }' %
  191. (LBL_FONT_COLOR, LBL_FONT_SIZE))
  192. print(' text.label-title { font-size: %dpx; text-anchor: middle; }' %
  193. (int(LBL_FONT_SIZE * 1.4),))
  194. print(' text.label-prn { font-size: %dpx; text-anchor: end; }' %
  195. (LBL_SAT_BAR_SIZE,))
  196. print(' text.label-center { text-anchor: middle; }')
  197. print(' text.label-snr { text-anchor: start; }')
  198. print(' text.label-err { text-anchor: end; }')
  199. # Compass dial.
  200. print(' circle.compass '
  201. '{ stroke: %s; stroke-width: %d; fill-opacity: 0; }' %
  202. (COMPASS_STROKE_COLOR, STROKE_WIDTH,))
  203. print(' line.compass { stroke: %s; stroke-width: %d; }' %
  204. (COMPASS_STROKE_COLOR, STROKE_WIDTH))
  205. print(' text.compass '
  206. '{ fill: %s; font-size: %dpx; text-anchor: middle; }' %
  207. (DIAL_POINT_COLOR, LBL_COMPASS_POINTS_SIZE))
  208. # Satellites constellation.
  209. print(' rect.sats { stroke-width: %d; fill-opacity: 1.0; }' %
  210. (STROKE_WIDTH,))
  211. print(' rect.sats-used { stroke: %s; fill: %s; }' %
  212. (SAT_USED_STROKE_COLOR, SAT_USED_FILL_COLOR))
  213. print(' rect.sats-unused { stroke: %s; fill: %s; }' %
  214. (SAT_UNUSED_STROKE_COLOR, SAT_UNUSED_FILL_COLOR))
  215. print(' text.sats { font-size: %dpx; text-anchor: middle; }' %
  216. (LBL_SAT_SIZE,))
  217. print(' text.sats-used { fill: %s; }' % (SAT_USED_TEXT_COLOR,))
  218. print(' text.sats-unused { fill: %s; }' % (SAT_UNUSED_TEXT_COLOR,))
  219. # Box containing bars graph.
  220. print(' rect.box { fill: %s; stroke: %s; stroke-width: %d; }' %
  221. (BARS_AREA_FILL_COLOR, BARS_AREA_STROKE_COLOR, STROKE_WIDTH))
  222. # Graph bars.
  223. print(' rect.bars { stroke-width: %d; opacity: 1.0; }' %
  224. (STROKE_WIDTH,))
  225. print(' rect.bars-used { stroke: %s; fill: %s; }' %
  226. (BAR_USED_STROKE_COLOR, BAR_USED_FILL_COLOR))
  227. print(' rect.bars-unused { stroke: %s; fill: %s; }' %
  228. (BAR_UNUSED_STROKE_COLOR, BAR_UNUSED_FILL_COLOR))
  229. print('</style>')
  230. # Background and title.
  231. print('<rect width="100%%" height="100%%" fill="%s" />' %
  232. (BACKGROUND_COLOR,))
  233. print('<text class="label label-title" x="%d" y="%d">'
  234. 'Sky View of GPS Satellites</text>' %
  235. (int(IMG_WIDTH * 0.5), int(LBL_FONT_SIZE * 1.5)))
  236. # Sky circle with cardinal points.
  237. print('<circle class="compass" cx="%d" cy="%d" r="%d" />' %
  238. (CIRCLE_X, CIRCLE_Y, CIRCLE_R))
  239. print('<circle class="compass" cx="%d" cy="%d" r="%d" />' %
  240. (CIRCLE_X, CIRCLE_Y, int(CIRCLE_R / 2)))
  241. print('<line class="compass" x1="%d" y1="%d" x2="%d" y2="%d" />' %
  242. (CIRCLE_X, CIRCLE_Y - CIRCLE_R, CIRCLE_X, CIRCLE_Y + CIRCLE_R))
  243. print('<line class="compass" x1="%d" y1="%d" x2="%d" y2="%d" />' %
  244. (CIRCLE_X - CIRCLE_R, CIRCLE_Y, CIRCLE_X + CIRCLE_R, CIRCLE_Y))
  245. print('<text x="%d" y="%d" class="compass">%s</text>' %
  246. (CIRCLE_X, CIRCLE_Y - CIRCLE_R - LBL_COMPASS_POINTS_SIZE, 'N'))
  247. print('<text x="%d" y="%d" class="compass">%s</text>' %
  248. (CIRCLE_X, CIRCLE_Y + CIRCLE_R + LBL_COMPASS_POINTS_SIZE, 'S'))
  249. print('<text x="%d" y="%d" class="compass">%s</text>' %
  250. (CIRCLE_X - CIRCLE_R - LBL_COMPASS_POINTS_SIZE,
  251. CIRCLE_Y + int(LBL_COMPASS_POINTS_SIZE*0.4), 'W'))
  252. print('<text x="%d" y="%d" class="compass">%s</text>' %
  253. (CIRCLE_X + CIRCLE_R + LBL_COMPASS_POINTS_SIZE,
  254. CIRCLE_Y + int(LBL_COMPASS_POINTS_SIZE*0.4), 'E'))
  255. # Lat/lon.
  256. POS_LAT = "%.5f" % (float(TPV['lat']),) if 'lat' in TPV else 'Unknown'
  257. POS_LON = "%.5f" % (float(TPV['lon']),) if 'lon' in TPV else 'Unknown'
  258. print('<text class="label label-center" x="%d" y="%d">Lat/Lon: %s %s</text>' %
  259. (POS_LBL_X, POS_LBL_Y, POS_LAT, POS_LON))
  260. # Satellites signal/noise ratio box.
  261. print('<rect class="box" x="%d" y="%d" rx="%d" ry="%d" '
  262. 'width="%d" height="%d" />' %
  263. (BARS_BOX_X, BARS_BOX_Y - BARS_BOX_HEIGHT, BAR_RADIUS,
  264. BAR_RADIUS, BARS_BOX_WIDTH, BARS_BOX_HEIGHT))
  265. SS_LBL_X = int(BARS_BOX_X + STROKE_WIDTH * 1.5)
  266. SS_LBL_Y = int(BARS_BOX_Y - BARS_BOX_HEIGHT + LBL_FONT_SIZE +
  267. STROKE_WIDTH * 1.5)
  268. print('<text class="label label-snr" x="%d" y="%d">'
  269. 'Satellites Signal/Noise Ratio</text>' % (SS_LBL_X, SS_LBL_Y))
  270. # Box for horizontal and vertical estimated error.
  271. if 'epx' in TPV and 'epy' in TPV:
  272. EPX = float(TPV['epx'])
  273. EPY = float(TPV['epy'])
  274. EPH = math.sqrt(EPX**2 + EPY**2)
  275. elif 'eph' in TPV:
  276. EPH = float(TPV['eph'])
  277. else:
  278. EPH = ERR_MAX
  279. EPV = float(TPV['epv']) if 'epv' in TPV else ERR_MAX
  280. ERR_H, SIGN_H = cutoff_err(EPH, ERR_MIN, ERR_MAX)
  281. ERR_V, SIGN_V = cutoff_err(EPV, ERR_MIN, ERR_MAX)
  282. ERR_LBL_X = int(ERR_BOX_X - STROKE_WIDTH * 2.0)
  283. ERR_LBL_Y_OFFSET = STROKE_WIDTH + BAR_WIDTH * 0.6
  284. print('<rect class="box" x="%d" y="%d" rx="%d" ry="%d" '
  285. 'width="%d" height="%d" />' %
  286. (ERR_BOX_X, ERR_BOX_Y - ERR_BOX_HEIGHT, BAR_RADIUS,
  287. BAR_RADIUS, ERR_BOX_WIDTH, ERR_BOX_HEIGHT))
  288. # Horizontal error.
  289. POS_X = ERR_BOX_X + STROKE_WIDTH
  290. POS_Y = ERR_BOX_Y - ERR_BOX_HEIGHT + int((BAR_SPACE - BAR_WIDTH) * 0.5)
  291. ERR_H_BAR_HEIGHT = int(ERR_H / ERR_MAX * ERR_BAR_HEIGHT_MAX)
  292. print('<text class="label label-err" x="%d" y="%d">'
  293. 'Horizontal error %s%.1f m</text>' %
  294. (ERR_LBL_X, ERR_LBL_Y_OFFSET + POS_Y, SIGN_H, ERR_H))
  295. print('<rect class="bars bars-used" x="%d" y="%d" rx="%d" '
  296. ' ry="%d" width="%d" height="%d" />' %
  297. (POS_X, POS_Y, BAR_RADIUS, BAR_RADIUS, ERR_H_BAR_HEIGHT, BAR_WIDTH))
  298. # Vertical error.
  299. POS_Y = POS_Y + BAR_SPACE
  300. ERR_V_BAR_HEIGHT = int(ERR_V / ERR_MAX * ERR_BAR_HEIGHT_MAX)
  301. print('<text class="label label-err" x="%d" y="%d">'
  302. 'Vertical error %s%.1f m</text>' %
  303. (ERR_LBL_X, ERR_LBL_Y_OFFSET + POS_Y, SIGN_V, ERR_V))
  304. print('<rect class="bars bars-used" x="%d" y="%d" rx="%d" '
  305. ' ry="%d" width="%d" height="%d" />' %
  306. (POS_X, POS_Y, BAR_RADIUS, BAR_RADIUS, ERR_V_BAR_HEIGHT, BAR_WIDTH))
  307. # Satellites and Signal/Noise bars.
  308. i = 0
  309. for prn in sorted(SATELLITES):
  310. sat = SATELLITES[prn]
  311. BAR_HEIGHT = int(BAR_HEIGHT_MAX * (float(sat['ss']) / SNR_MAX))
  312. (sat_x, sat_y) = polar2cart(float(sat['az']), float(sat['el']), CIRCLE_R)
  313. sat_x = int(CIRCLE_X + sat_x)
  314. sat_y = int(CIRCLE_Y + sat_y)
  315. rect_radius = int(SAT_HEIGHT * 0.25)
  316. sat_rect_x = int(sat_x - (SAT_WIDTH) / 2)
  317. sat_rect_y = int(sat_y - (SAT_HEIGHT) / 2)
  318. sat_class = 'used' if sat['used'] else 'unused'
  319. print('<rect class="sats sats-%s" x="%d" y="%d" width="%d" '
  320. ' height="%d" rx="%d" ry="%d" />' %
  321. (sat_class, sat_rect_x, sat_rect_y, SAT_WIDTH, SAT_HEIGHT,
  322. rect_radius, rect_radius))
  323. print('<text class="sats %s" x="%d" y="%d">%s</text>' %
  324. (sat_class, sat_x, sat_y + int(LBL_SAT_SIZE*0.4), sat['PRN']))
  325. pos_x = (int(BARS_BOX_X + (STROKE_WIDTH * 0.5) +
  326. (BAR_SPACE - BAR_WIDTH) * 0.5 + BAR_SPACE * i))
  327. pos_y = int(BARS_BOX_Y - BAR_HEIGHT - (STROKE_WIDTH * 1.5))
  328. print('<rect class="bars bars-%s" x="%d" y="%d" rx="%d" ry="%d" '
  329. 'width="%d" height="%d" />' %
  330. (sat_class, pos_x, pos_y, BAR_RADIUS, BAR_RADIUS,
  331. BAR_WIDTH, BAR_HEIGHT))
  332. x = int(pos_x + BAR_WIDTH * 0.5)
  333. y = int(BARS_BOX_Y + (STROKE_WIDTH * 1.5))
  334. print('<text class="label label-prn" x="%d" y="%d" '
  335. 'transform="rotate(270, %d, %d)">%s</text>' %
  336. (x, y, x, y, sat['PRN']))
  337. i = i + 1
  338. print('<text class="label label-center" x="%d" y="%d">%s</text>' %
  339. (TIMESTAMP_X, TIMESTAMP_Y, TIME_STR))
  340. print('</svg>')
  341. sys.exit(EXIT_CODE)