├── .gitignore ├── README.md ├── gps.py ├── main.py └── resources ├── dawn.stl ├── earth_day.jpg ├── earth_night.jpg ├── moon.jpg └── stars.png /.gitignore: -------------------------------------------------------------------------------- 1 | env 2 | 3 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## GPS 2 | 3 | Real-time 3D renderings of GPS satellite locations. 4 | 5 | http://www.michaelfogleman.com/gps/ 6 | 7 | ![Screenshot](http://www.michaelfogleman.com/static/img/project/gps/gps.png) 8 | 9 | ### Hardware 10 | 11 | [GlobalSat BU-353-S4 USB GPS Receiver](http://www.amazon.com/GlobalSat-BU-353-S4-USB-Receiver-Black/dp/B008200LHW/) 12 | 13 | ### Dependencies 14 | 15 | pip install ephem pg pyserial 16 | 17 | `pg` requires a glfw3 binary. On Mac, it's easy with Homebrew: 18 | 19 | brew tap homebrew/versions 20 | brew install glfw3 21 | -------------------------------------------------------------------------------- /gps.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import serial 3 | 4 | # Settings 5 | PORT = '/dev/cu.usbserial' 6 | BAUD_RATE = 4800 7 | 8 | # Dilution of Precision 9 | DOP_EXCELLENT = 1 10 | DOP_GOOD = 2 11 | DOP_MODERATE = 3 12 | DOP_FAIR = 4 13 | DOP_POOR = 5 14 | 15 | DOP_STRING = { 16 | DOP_EXCELLENT: 'excellent', 17 | DOP_GOOD: 'good', 18 | DOP_MODERATE: 'moderate', 19 | DOP_FAIR: 'fair', 20 | DOP_POOR: 'poor', 21 | } 22 | 23 | # Helper Functions 24 | def to_decimal(value, nsew): 25 | if not value: 26 | return None 27 | a, b = value.split('.') 28 | degrees = int(a) / 100 29 | minutes = int(a) % 100 30 | seconds = 60.0 * int(b) / 10 ** len(b) 31 | result = degrees + minutes / 60.0 + seconds / 3600.0 32 | if nsew in 'SW': 33 | result = -result 34 | return result 35 | 36 | def to_dop(value): 37 | if value < 2: 38 | return DOP_EXCELLENT 39 | if value < 5: 40 | return DOP_GOOD 41 | if value < 10: 42 | return DOP_MODERATE 43 | if value < 20: 44 | return DOP_FAIR 45 | return DOP_POOR 46 | 47 | def parse_int(x): 48 | return int(x) if x else None 49 | 50 | def parse_float(x): 51 | return float(x) if x else None 52 | 53 | # Model Objects 54 | class Record(object): 55 | def __init__(self, **kwargs): 56 | # $GPRMC... 57 | # valid fix 58 | self.valid = kwargs['valid'] 59 | # timestamp of fix 60 | self.timestamp = kwargs['timestamp'] 61 | # latitude of fix 62 | self.latitude = kwargs['latitude'] 63 | # longitude of fix 64 | self.longitude = kwargs['longitude'] 65 | # speed over ground in knots 66 | self.knots = kwargs['knots'] 67 | # true course in degrees 68 | self.course = kwargs['course'] 69 | # $GPGSA... 70 | # mode: 1 = no fix, 2 = 2D, 3 = 3D 71 | self.mode = kwargs['mode'] 72 | # satellite prns used in position fix 73 | self.prns = kwargs['prns'] 74 | # 3D position DOP 75 | self.pdop = kwargs['pdop'] 76 | # horizontal DOP 77 | self.hdop = kwargs['hdop'] 78 | # vertical DOP 79 | self.vdop = kwargs['vdop'] 80 | # $GPGGA... 81 | # fix quality: 0 = invalid, 1 = gps fix, 2 = dgps fix 82 | self.fix = kwargs['fix'] 83 | # number of satellites 84 | self.count = kwargs['count'] 85 | # altitude above mean sea level 86 | self.altitude = kwargs['altitude'] 87 | # geoidal separation: height above WGS84 ellipsoid 88 | self.separation = kwargs['separation'] 89 | def __repr__(self): 90 | keys = [ 91 | 'valid', 'timestamp', 'latitude', 'longitude', 'knots', 'course', 92 | 'mode', 'prns', 'pdop', 'hdop', 'vdop', 93 | 'fix', 'count', 'altitude', 'separation', 94 | ] 95 | rows = [' %s = %r,' % (key, getattr(self, key)) for key in keys] 96 | rows = '\n'.join(rows) 97 | return 'Record(\n%s\n)' % rows 98 | 99 | class Satellite(object): 100 | def __init__(self, prn, elevation, azimuth, snr): 101 | self.prn = prn 102 | self.elevation = elevation 103 | self.azimuth = azimuth 104 | self.snr = snr 105 | def __repr__(self): 106 | return 'Satellite(%r, %r, %r, %r)' % ( 107 | self.prn, self.elevation, self.azimuth, self.snr) 108 | 109 | # Device Object 110 | class Device(object): 111 | def __init__(self, port=PORT, baud_rate=BAUD_RATE): 112 | self.port = serial.Serial(port, baud_rate) 113 | self.handlers = { 114 | '$GPGGA': self.on_gga, 115 | '$GPGSA': self.on_gsa, 116 | '$GPRMC': self.on_rmc, 117 | '$GPGSV': self.on_gsv, 118 | } 119 | self.gga = None 120 | self.gsa = None 121 | self.rmc = None 122 | self.record = None 123 | self.gsv = {} 124 | self.satellites = {} 125 | def run(self): 126 | while True: 127 | self.parse_line() 128 | def read_line(self): 129 | while True: 130 | line = self.port.readline().strip() 131 | if line.startswith('$'): 132 | return line 133 | def parse_line(self): 134 | line = self.read_line() 135 | data, checksum = line.split('*') 136 | tokens = data.split(',') 137 | command, args = tokens[0], tokens[1:] 138 | handler = self.handlers.get(command) 139 | if handler: 140 | handler(args) 141 | def on_gga(self, args): 142 | timestamp = datetime.datetime.strptime(args[0], '%H%M%S.%f').time() 143 | latitude = to_decimal(args[1], args[2]) 144 | longitude = to_decimal(args[3], args[4]) 145 | fix = parse_int(args[5]) 146 | count = parse_int(args[6]) 147 | hdop = parse_float(args[7]) 148 | altitude = parse_float(args[8]) 149 | separation = parse_float(args[10]) 150 | self.gga = dict( 151 | timestamp=timestamp, 152 | latitude=latitude, 153 | longitude=longitude, 154 | fix=fix, 155 | count=count, 156 | hdop=hdop, 157 | altitude=altitude, 158 | separation=separation, 159 | ) 160 | def on_gsa(self, args): 161 | mode = parse_int(args[1]) 162 | prns = map(int, filter(None, args[2:14])) 163 | pdop = parse_float(args[14]) 164 | hdop = parse_float(args[15]) 165 | vdop = parse_float(args[16]) 166 | self.gsa = dict( 167 | mode=mode, 168 | prns=prns, 169 | pdop=pdop, 170 | hdop=hdop, 171 | vdop=vdop, 172 | ) 173 | def on_rmc(self, args): 174 | if self.gga is None or self.gsa is None: 175 | return 176 | valid = args[1] == 'A' 177 | timestamp = datetime.datetime.strptime(args[8] + args[0], 178 | '%d%m%y%H%M%S.%f') 179 | latitude = to_decimal(args[2], args[3]) 180 | longitude = to_decimal(args[4], args[5]) 181 | knots = parse_float(args[6]) 182 | course = parse_float(args[7]) 183 | self.rmc = dict( 184 | valid=valid, 185 | timestamp=timestamp, 186 | latitude=latitude, 187 | longitude=longitude, 188 | knots=knots, 189 | course=course, 190 | ) 191 | data = {} 192 | data.update(self.gga) 193 | data.update(self.gsa) 194 | data.update(self.rmc) 195 | record = Record(**data) 196 | self.on_record(record) 197 | def on_gsv(self, args): 198 | count = int(args[0]) 199 | index = int(args[1]) 200 | if index == 1: 201 | self.gsv = {} 202 | for i in range(3, len(args), 4): 203 | data = args[i:i+4] 204 | if all(data): 205 | data = map(int, data) 206 | satellite = Satellite(*data) 207 | self.gsv[satellite.prn] = satellite 208 | if index == count: 209 | self.on_satellites(dict(self.gsv)) 210 | def on_record(self, record): 211 | self.record = record 212 | print self.record 213 | print 214 | for key in sorted(self.satellites): 215 | print self.satellites[key] 216 | print 217 | def on_satellites(self, satellites): 218 | self.satellites = satellites 219 | 220 | # Main 221 | def main(): 222 | device = Device() 223 | device.run() 224 | 225 | if __name__ == '__main__': 226 | main() 227 | -------------------------------------------------------------------------------- /main.py: -------------------------------------------------------------------------------- 1 | from math import radians, degrees, pi, asin, sin, cos, atan2 2 | from OpenGL.GL import * 3 | import ephem 4 | import gps 5 | import pg 6 | 7 | EARTH_RADIUS = 6371 8 | MOON_RADIUS = 1737.1 9 | AU = 149597870.7 10 | ALTITUDE = 20200 11 | SPEED = 10000 12 | SATELLITE_SCALE = 20 13 | FONT = '/Library/Fonts/Arial.ttf' 14 | 15 | ZNEAR = 1 16 | ZFAR = 1000000 17 | 18 | def to_xyz(lat, lng, elevation, azimuth, altitude=ALTITUDE): 19 | r1 = EARTH_RADIUS 20 | r2 = r1 + altitude 21 | aa = radians(elevation) + pi / 2 22 | ar = asin(r1 * sin(aa) / r2) 23 | ad = pi - aa - ar 24 | angle = pi / 2 - ad 25 | x = cos(angle) * r2 26 | z = sin(angle) * r2 27 | matrix = pg.Matrix() 28 | matrix = matrix.rotate((0, 0, -1), pi / 2 - radians(azimuth)) 29 | matrix = matrix.rotate((-1, 0, 0), -radians(lat)) 30 | matrix = matrix.rotate((0, -1, 0), radians(lng)) 31 | return matrix * (x, 0, z) 32 | 33 | class Window(pg.Window): 34 | def setup(self): 35 | self.device = gps.Device() 36 | pg.async(self.device.run) 37 | self.fix = False 38 | self.font = pg.Font(self, 3, FONT, 18, bg=(0, 0, 0)) 39 | self.wasd = pg.WASD(self, speed=SPEED) 40 | self.wasd.look_at((0, 0, EARTH_RADIUS + ALTITUDE * 2), (0, 0, 0)) 41 | # stars 42 | self.stars = pg.Context(StarsProgram()) 43 | self.stars.sampler = pg.Texture(2, 'resources/stars.png') 44 | self.stars_sphere = pg.Sphere(4).reverse_winding() 45 | # earth 46 | self.earth = pg.Context(EarthProgram()) 47 | self.earth.day = pg.Texture(0, 'resources/earth_day.jpg') 48 | self.earth.night = pg.Texture(1, 'resources/earth_night.jpg') 49 | self.earth.ambient_color = (0.4, 0.4, 0.4) 50 | self.earth.light_color = (1.25, 1.25, 1.25) 51 | self.earth.specular_power = 20.0 52 | self.earth.specular_multiplier = 0.3 53 | self.earth_sphere = pg.Sphere(5, EARTH_RADIUS) 54 | # moon 55 | self.moon = pg.Context(pg.DirectionalLightProgram()) 56 | self.moon.use_texture = True 57 | self.moon.sampler = pg.Texture(4, 'resources/moon.jpg') 58 | self.moon.ambient_color = (0.1, 0.1, 0.1) 59 | self.moon.light_color = (1.3, 1.3, 1.3) 60 | self.moon.specular_power = 20.0 61 | self.moon.specular_multiplier = 0.3 62 | self.moon_sphere = pg.Sphere(4, MOON_RADIUS) 63 | # satellites 64 | self.context = pg.Context(pg.DirectionalLightProgram()) 65 | self.context.object_color = (1, 1, 1) 66 | m = SATELLITE_SCALE 67 | self.satellite = pg.STL('resources/dawn.stl').center() 68 | self.satellite = pg.Matrix().scale((m, m, m)) * self.satellite 69 | # lines 70 | self.lines = pg.Context(pg.SolidColorProgram()) 71 | self.lines.color = (1, 1, 1, 0.25) 72 | def get_lat_lng(self): 73 | record = self.device.record 74 | valid = record and record.valid 75 | lat = record.latitude if valid else 0 76 | lng = record.longitude if valid else 0 77 | return (lat, lng) 78 | def get_position(self): 79 | lat, lng = self.get_lat_lng() 80 | return to_xyz(lat, lng, 0, 0, 0) 81 | def get_positions(self): 82 | result = [] 83 | lat, lng = self.get_lat_lng() 84 | for satellite in self.device.satellites.values(): 85 | result.append(to_xyz( 86 | lat, lng, satellite.elevation, satellite.azimuth)) 87 | return result 88 | def get_sun(self): 89 | lat, lng = self.get_lat_lng() 90 | observer = ephem.Observer() 91 | observer.lat = radians(lat) 92 | observer.lon = radians(lng) 93 | sun = ephem.Sun(observer) 94 | elevation = degrees(sun.alt) 95 | azimuth = degrees(sun.az) 96 | return pg.normalize(to_xyz(lat, lng, elevation, azimuth)) 97 | def get_moon(self): 98 | lat, lng = self.get_lat_lng() 99 | observer = ephem.Observer() 100 | observer.lat = radians(lat) 101 | observer.lon = radians(lng) 102 | moon = ephem.Moon(observer) 103 | elevation = degrees(moon.alt) 104 | azimuth = degrees(moon.az) 105 | distance = moon.earth_distance * AU 106 | altitude = distance - EARTH_RADIUS 107 | return to_xyz(lat, lng, elevation, azimuth, altitude) 108 | def rotate_satellite(self, position): 109 | dx, dy, dz = pg.normalize(position) 110 | rx = atan2(dz, dx) + pi / 2 111 | ry = asin(dy) - pi / 2 112 | matrix = pg.Matrix() 113 | matrix = matrix.rotate((0, 1, 0), rx) 114 | matrix = matrix.rotate((cos(rx), 0, sin(rx)), -ry) 115 | return matrix 116 | def rotate_moon(self, position): 117 | # TODO: account for libration 118 | dx, dy, dz = pg.normalize(position) 119 | rx = atan2(dz, dx) + pi / 2 120 | ry = asin(dy) 121 | matrix = pg.Matrix() 122 | matrix = matrix.rotate((0, 1, 0), rx) 123 | matrix = matrix.rotate((cos(rx), 0, sin(rx)), -ry) 124 | return matrix 125 | def draw_lines(self): 126 | bits = ('0' * 4 + '1' * 4) * 2 127 | shift = int(self.t * 16) % len(bits) 128 | bits = bits[shift:] + bits[:shift] 129 | glLineStipple(1, int(bits, 2)) 130 | glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA) 131 | matrix = self.wasd.get_matrix() 132 | matrix = matrix.perspective(65, self.aspect, ZNEAR, ZFAR) 133 | self.lines.matrix = matrix 134 | data = [] 135 | x1, y1, z1 = self.get_position() 136 | for x2, y2, z2 in self._positions: 137 | data.append((x2, y2, z2)) 138 | data.append((x1, y1, z1)) 139 | if data: 140 | self.lines.position = pg.VertexBuffer(data) 141 | glEnable(GL_BLEND) 142 | glEnable(GL_LINE_STIPPLE) 143 | self.lines.draw(pg.GL_LINES) 144 | glDisable(GL_LINE_STIPPLE) 145 | glDisable(GL_BLEND) 146 | self.lines.position.delete() 147 | def draw_satellite(self, position): 148 | self.context.camera_position = self.wasd.position 149 | self.context.light_direction = self._sun 150 | matrix = self.rotate_satellite(position) 151 | self.context.normal_matrix = matrix.inverse().transpose() 152 | matrix = matrix.translate(position) 153 | self.context.model_matrix = matrix 154 | matrix = self.wasd.get_matrix(matrix) 155 | matrix = matrix.perspective(65, self.aspect, ZNEAR, ZFAR) 156 | self.context.matrix = matrix 157 | self.satellite.draw(self.context) 158 | def draw_earth(self): 159 | self.earth.camera_position = self.wasd.position 160 | self.earth.light_direction = self._sun 161 | matrix = self.wasd.get_matrix() 162 | matrix = matrix.perspective(65, self.aspect, ZNEAR, ZFAR) 163 | self.earth.matrix = matrix 164 | self.earth_sphere.draw(self.earth) 165 | def draw_moon(self): 166 | self.moon.camera_position = self.wasd.position 167 | self.moon.light_direction = self._sun 168 | position = self.get_moon() 169 | matrix = self.rotate_moon(position) 170 | self.moon.normal_matrix = matrix.inverse().transpose() 171 | matrix = matrix.translate(position) 172 | self.moon.model_matrix = matrix 173 | matrix = self.wasd.get_matrix(matrix) 174 | matrix = matrix.perspective(65, self.aspect, ZNEAR, ZFAR) 175 | self.moon.matrix = matrix 176 | self.moon_sphere.draw(self.moon) 177 | def draw_stars(self): 178 | matrix = self.wasd.get_matrix(translate=False) 179 | matrix = matrix.perspective(65, self.aspect, 0.1, 1) 180 | self.stars.matrix = matrix 181 | self.stars_sphere.draw(self.stars) 182 | def draw_text(self): 183 | w, h = self.size 184 | record = self.device.record 185 | if record and record.timestamp: 186 | self.font.render(record.timestamp.isoformat(), (5, 0)) 187 | def update(self, t, dt): 188 | # position camera on first gps fix 189 | lat, lng = self.get_lat_lng() 190 | if not self.fix and any((lat, lng)): 191 | camera = to_xyz(lat, lng, 90, 0, ALTITUDE * 2) 192 | self.wasd.look_at(camera, (0, 0, 0)) 193 | self.fix = True 194 | # cache some values for the draw step 195 | self._sun = self.get_sun() 196 | self._positions = self.get_positions() 197 | def draw(self): 198 | self.clear() 199 | self.draw_stars() 200 | self.clear_depth_buffer() 201 | self.draw_earth() 202 | self.draw_moon() 203 | for position in self._positions: 204 | self.draw_satellite(position) 205 | self.draw_lines() 206 | self.draw_text() 207 | 208 | class EarthProgram(pg.BaseProgram): 209 | VS = ''' 210 | #version 120 211 | 212 | uniform mat4 matrix; 213 | 214 | attribute vec4 position; 215 | attribute vec3 normal; 216 | attribute vec2 uv; 217 | 218 | varying vec3 frag_position; 219 | varying vec3 frag_normal; 220 | varying vec2 frag_uv; 221 | 222 | void main() { 223 | gl_Position = matrix * position; 224 | frag_position = vec3(position); 225 | frag_normal = normal; 226 | frag_uv = uv; 227 | } 228 | ''' 229 | FS = ''' 230 | #version 120 231 | 232 | uniform sampler2D day; 233 | uniform sampler2D night; 234 | uniform vec3 camera_position; 235 | 236 | uniform vec3 light_direction; 237 | uniform vec3 ambient_color; 238 | uniform vec3 light_color; 239 | uniform float specular_power; 240 | uniform float specular_multiplier; 241 | 242 | varying vec3 frag_position; 243 | varying vec3 frag_normal; 244 | varying vec2 frag_uv; 245 | 246 | void main() { 247 | float diffuse = max(dot(frag_normal, light_direction), 0.0); 248 | vec3 day_color = vec3(texture2D(day, frag_uv)); 249 | vec3 night_color = vec3(texture2D(night, frag_uv)); 250 | float pct = 1.0 - pow(1.0 - diffuse, 4.0); 251 | vec3 color = mix(night_color, day_color, pct); 252 | float specular = 0.0; 253 | if (diffuse > 0.0) { 254 | vec3 camera_vector = normalize(camera_position - frag_position); 255 | specular = pow(max(dot(camera_vector, 256 | reflect(-light_direction, frag_normal)), 0.0), specular_power); 257 | } 258 | vec3 light = ambient_color + light_color * diffuse; 259 | vec3 spec = vec3(1.0, 1.0, 0.9) * specular * specular_multiplier; 260 | gl_FragColor = vec4(min(color * light + spec, vec3(1.0)), 1.0); 261 | } 262 | ''' 263 | 264 | class StarsProgram(pg.BaseProgram): 265 | VS = ''' 266 | #version 120 267 | 268 | uniform mat4 matrix; 269 | 270 | attribute vec4 position; 271 | attribute vec2 uv; 272 | 273 | varying vec2 frag_uv; 274 | 275 | void main() { 276 | gl_Position = matrix * position; 277 | frag_uv = uv; 278 | } 279 | ''' 280 | FS = ''' 281 | #version 120 282 | 283 | uniform sampler2D sampler; 284 | 285 | varying vec2 frag_uv; 286 | 287 | void main() { 288 | vec3 color = vec3(texture2D(sampler, frag_uv)); 289 | color = pow(color, vec3(2.0)); 290 | color = mix(vec3(0.0), color, 0.5); 291 | gl_FragColor = vec4(color, 1.0); 292 | } 293 | ''' 294 | 295 | if __name__ == "__main__": 296 | pg.run(Window) 297 | -------------------------------------------------------------------------------- /resources/dawn.stl: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fogleman/GPS/84ff57ccaacff10e908afc80b1aa3714cb88727a/resources/dawn.stl -------------------------------------------------------------------------------- /resources/earth_day.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fogleman/GPS/84ff57ccaacff10e908afc80b1aa3714cb88727a/resources/earth_day.jpg -------------------------------------------------------------------------------- /resources/earth_night.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fogleman/GPS/84ff57ccaacff10e908afc80b1aa3714cb88727a/resources/earth_night.jpg -------------------------------------------------------------------------------- /resources/moon.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fogleman/GPS/84ff57ccaacff10e908afc80b1aa3714cb88727a/resources/moon.jpg -------------------------------------------------------------------------------- /resources/stars.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fogleman/GPS/84ff57ccaacff10e908afc80b1aa3714cb88727a/resources/stars.png --------------------------------------------------------------------------------