skyview2svg 14 KB

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