sgslib 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,362 @@
1
+ #
2
+ # Copyright (c) 2013, Kalopa Research. All rights reserved. This is free
3
+ # software; you can redistribute it and/or modify it under the terms of the
4
+ # GNU General Public License as published by the Free Software Foundation;
5
+ # either version 2, or (at your option) any later version.
6
+ #
7
+ # It is distributed in the hope that it will be useful, but WITHOUT
8
+ # ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
9
+ # FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
10
+ # for more details.
11
+ #
12
+ # You should have received a copy of the GNU General Public License along
13
+ # with this product; see the file COPYING. If not, write to the Free
14
+ # Software Foundation, 675 Mass Ave, Cambridge, MA 02139, USA.
15
+ #
16
+ # THIS SOFTWARE IS PROVIDED BY KALOPA RESEARCH "AS IS" AND ANY EXPRESS OR
17
+ # IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES
18
+ # OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED.
19
+ # IN NO EVENT SHALL KALOPA RESEARCH BE LIABLE FOR ANY DIRECT, INDIRECT,
20
+ # INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING,
21
+ # BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF
22
+ # USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
23
+ # ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
24
+ # (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
25
+ # OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
26
+ #
27
+
28
+ ##
29
+ # Routines for handling sailboat navigation and route planning.
30
+ #
31
+ require 'date'
32
+
33
+ module SGS
34
+ #
35
+ # Handle a specific mission.
36
+ class Mission
37
+ attr_accessor :id, :where, :time, :start_time
38
+ attr_accessor :course, :heading, :twa, :vmg
39
+ attr_accessor :waypoints, :repellors, :track
40
+ attr_accessor :root_dir
41
+
42
+ #
43
+ # Create the attractors and repellors
44
+ def initialize(id = 0)
45
+ @root_dir = "."
46
+ @waypoints = []
47
+ @repellors = []
48
+ @track = []
49
+ @id = id
50
+ @time = DateTime.now
51
+ @wpt_index = -1
52
+ @heading = -1
53
+ @course = -1
54
+ @tack = 0
55
+ end
56
+
57
+ #
58
+ # Load a new mission from the missions directory.
59
+ def self.load(id)
60
+ mission = new
61
+ mission.id = id
62
+ mission.read(File.open(mission.filename))
63
+ mission
64
+ end
65
+
66
+ #
67
+ # Parse a mission file.
68
+ def read(file)
69
+ file.each do |line|
70
+ unless line =~ /^#/
71
+ args = line.split(':')
72
+ code = args[0]
73
+ loc = Location.parse_str(args[1])
74
+ vec = Bearing.degrees(args[2], args[3])
75
+ name = args[4].strip
76
+ case code
77
+ when /\d/
78
+ @waypoints[code.to_i] = Waypoint.new(loc, vec, name)
79
+ when /[Xx]/
80
+ @where = loc
81
+ @wind = vec
82
+ compute_heading if active?
83
+ when /[Rr]/
84
+ @repellors << Waypoint.new(loc, vec, name, Waypoint::REPELLOR)
85
+ end
86
+ end
87
+ end
88
+ @wpt_index = -1
89
+ end
90
+
91
+ #
92
+ # Save the mission.
93
+ def save
94
+ write(File.open(filename, 'w'))
95
+ end
96
+
97
+ #
98
+ # Write a mission to a file.
99
+ def write(file)
100
+ file.puts "#\n# My starting position."
101
+ file.puts "X:%s:0:12:Starting position" % @where
102
+ file.puts "#\n# Waypoints."
103
+ @waypoints.each_with_index do |wpt, i|
104
+ file.puts "%d:%s:%d:%f:%s" % [i,
105
+ wpt.location,
106
+ wpt.chord.angle_degrees,
107
+ wpt.chord.distance,
108
+ wpt.name]
109
+ end
110
+ end
111
+
112
+ #
113
+ # Commence a mission...
114
+ def commence(time = nil)
115
+ @start_time = time || DateTime.now
116
+ @time = @start_time
117
+ @wpt_index = 0
118
+ @have_new_waypoint = true
119
+ @have_new_heading = true
120
+ @have_new_tack = false
121
+ end
122
+
123
+ #
124
+ # Terminate a mission.
125
+ def terminate
126
+ @wpt_index = -1
127
+ end
128
+
129
+ #
130
+ # On-mission means we have something to do. In other words, we have a
131
+ # waypoint to get to.
132
+ def active?
133
+ @wpt_index >= 0 and @wpt_index < @waypoints.count
134
+ end
135
+
136
+ #
137
+ # Set our current location (and timestamp).
138
+ def set_location(where, time = nil)
139
+ @where = where
140
+ @time = time || DateTime.now
141
+ #
142
+ # Update the track with our new position, and find the next waypoint.
143
+ @track << Track.new(@time, @where.clone)
144
+ again = false
145
+ begin
146
+ wpt = @waypoints[@wpt_index]
147
+ puts ">>Waypoint is #{wpt}"
148
+ wpt.compute_bearing(@where)
149
+ puts ">>Course #{wpt.bearing}"
150
+ if wpt.in_scope?
151
+ puts "We're at the mark. Time to find the next waypoint..."
152
+ @wpt_index += 1
153
+ @have_new_waypoint = again = true
154
+ else
155
+ again = false
156
+ end
157
+ end while again and active?
158
+ #
159
+ # OK, now compute our heading...
160
+ if active?
161
+ newc = wpt.bearing.angle_degrees
162
+ if newc != @course
163
+ puts "New course: #{newc}"
164
+ @course = newc
165
+ compute_heading
166
+ end
167
+ end
168
+ end
169
+ #
170
+ # Compute the most effective TWA/VMG for the course.
171
+ # Because I tend to forget, here's a run-down of the terminology...
172
+ # @course is where we're trying to get to. It is our compass bearing to the
173
+ # next mark or finish or whatever. @heading is the compass bearing we're
174
+ # going to sail to, which may not be the same as our course (we can't sail
175
+ # upwind, for example). alpha is the difference between our heading and the
176
+ # course. @twa is the true wind angle, relative to the front of the boat.
177
+ # Negative values mean a port tack, positive values are starboard. @vmg is
178
+ # the velocity made-good. In other words, the speed at which we're heading
179
+ # to the mark. It's defined as Vcos(alpha) where V is the velocity through
180
+ # the water.
181
+ def compute_heading
182
+ puts "Computing Heading and TWA..."
183
+ wad = @wind.angle_degrees.to_i
184
+ puts "Wind angle:%03dd" % wad
185
+ puts "Required course:%03dd" % @course
186
+ polar = Polar.new
187
+ @twa ||= 0
188
+ #
189
+ # Try an alpha angle between -60 and +60 degrees either side of the
190
+ # course. The point of this exercise is to compute the TWA for that
191
+ # heading, compute the speed at that trial TWA, and then compute
192
+ # the VMG for it. We compute the maximum VMG (and TWA) and use that
193
+ # to drive the boat. The PVMG is the poisoned version of the VMG,
194
+ # adjusted so that the opposite tack is less-favoured.
195
+ ideal_twa = 0
196
+ ideal_vmg = 0.0
197
+ max_pvmg = 0.0
198
+ curr_tack = @twa < 0.0 ? -1 : 1
199
+ puts "Current tack is %d" % curr_tack
200
+ (-60..60).each do |alpha|
201
+ newh = Bearing.absolute_degrees(@course + alpha)
202
+ twa = 180 - (720 + wad - @course - alpha) % 360
203
+ puts "Trial heading of %03dd (alpha=%d, TWA=%d)" % [newh, alpha, twa]
204
+ speed = polar.speed(Bearing.degrees_to_radians(twa))
205
+ vmg = polar.vmg(Bearing.degrees_to_radians(alpha))
206
+ #
207
+ # Adjust the speed to favour the current tack.
208
+ tack_err = twa < 0.0 ? -curr_tack : curr_tack
209
+ pvmg = vmg + vmg * tack_err / 5.0
210
+ if vmg > 1.7 and false
211
+ puts "Trial TWA: %3d, speed:%.5fkn, VMG:%.6fkn" % [twa, speed, vmg]
212
+ end
213
+ if pvmg > max_pvmg
214
+ max_pvmg = pvmg
215
+ ideal_twa = twa
216
+ ideal_vmg = vmg
217
+ end
218
+ end
219
+ #
220
+ # For the various angles, we have computed the best TWA and VMG. Now
221
+ # adjust our settings, accordingly. Don't use the poisoned VMG.
222
+ @twa = ideal_twa
223
+ @vmg = ideal_vmg
224
+ @have_new_tack = (@twa * curr_tack) < 0
225
+ puts "> Best TWA is %d, VMG is %.6fknots" % [@twa, @vmg]
226
+ #
227
+ # Check to see if we have a new heading.
228
+ newh = Bearing::absolute_degrees(wad - @twa)
229
+ puts "> HDG:%03dd, Err:%03dd" % [newh, newh - @course]
230
+ if newh != @heading
231
+ puts "New Heading! %03dd" % newh
232
+ @have_new_heading = true
233
+ @heading = newh
234
+ end
235
+ end
236
+
237
+ #
238
+ # Set the wind data.
239
+ def wind_data(angle, speed = 0.0)
240
+ @wind = Bearing.degrees(angle, speed)
241
+ puts "Wind:#{@wind}"
242
+ compute_heading if active?
243
+ end
244
+
245
+ #
246
+ # Wind speed (Beaufort scale)
247
+ BEAUFORT = [0, 1, 4, 7, 11, 17, 22, 28, 34, 41]
248
+ def wind_beaufort
249
+ ws = @wind.speed
250
+ end
251
+
252
+ #
253
+ # Are we at a new waypoint?
254
+ def new_waypoint?
255
+ hnw = @have_new_waypoint
256
+ @have_new_waypoint = false
257
+ active? and hnw
258
+ end
259
+
260
+ #
261
+ # Do we have a new heading?
262
+ def new_heading?
263
+ hnh = @have_new_heading
264
+ @have_new_heading = false
265
+ active? and hnh
266
+ end
267
+
268
+ #
269
+ # Do we need to tack?
270
+ def tacking?
271
+ hnt = @have_new_tack
272
+ @have_new_tack = false
273
+ active? and hnt
274
+ end
275
+
276
+ #
277
+ # Return the next (current) waypoint.
278
+ def waypoint
279
+ active? ? @waypoints[@wpt_index] : nil
280
+ end
281
+ #
282
+ # Return the mission status as a string
283
+ def status_str
284
+ mins = (@time - @start_time) * 24.0 * 60.0
285
+ hours = (mins / 60.0).to_i
286
+ days = hours / 24
287
+ mins = (mins % 60.0).to_i
288
+ hours %= 24
289
+ str = ">>>#{@time}, "
290
+ if days < 1
291
+ str += "%dh%dm" % [hours, mins]
292
+ else
293
+ str += "%dd%dh" % [days, mins]
294
+ end
295
+ str + ": My position is #{@where}"
296
+ end
297
+
298
+ #
299
+ # Name/path of mission file.
300
+ def filename
301
+ @root_dir + "/missions/%03d" % id
302
+ end
303
+
304
+ #
305
+ # Save the track.
306
+ def track_save
307
+ kml_write(File.open(track_filename, 'w'))
308
+ end
309
+
310
+ #
311
+ # The name of the track file
312
+ def track_filename
313
+ @root_dir + "/tracks/output-%03d.kml" % id
314
+ end
315
+
316
+ #
317
+ # Write the track data as a KML file.
318
+ def kml_write(file)
319
+ builder = Nokogiri::XML::Builder.new do |xml|
320
+ xml.kml('xmlns' => 'http://www.opengis.net/kml/2.2',
321
+ 'xmlns:gx' => 'http://www.google.com/kml/ext/2.2') {
322
+ xml.Folder {
323
+ xml_line_style(xml, "waypointLine", "0xffcf0000", 4)
324
+ xml_line_style(xml, "repellorLine", "0xff00007f", 4)
325
+ xml_line_style(xml, "trackLine")
326
+ @waypoints.each do |wpt|
327
+ xml.Placemark {
328
+ xml.name wpt.name
329
+ xml.styleUrl "#waypointLine"
330
+ xml.LineString {
331
+ xml.extrude 1
332
+ xml.tessellate 1
333
+ xml.coordinates wpt.to_kml
334
+ }
335
+ }
336
+ xml.Placemark {
337
+ xml.styleUrl "#waypointLine"
338
+ xml.LineString {
339
+ xml.extrude 1
340
+ xml.tessellate 1
341
+ xml.coordinates wpt.to_axis_kml
342
+ }
343
+ }
344
+ end
345
+ # Requires a hack to get rid of the 'gx:' for the when tag.
346
+ file.puts builder.to_xml.gsub(/GX_/, 'gx:')
347
+ end
348
+
349
+ #
350
+ # Do a line style. The colour is of the form aabbggrr for some unknown
351
+ # reason...
352
+ def xml_line_style(xml, label, color = "0xffffffff", width = 1)
353
+ xml.Style(:id => label) {
354
+ xml.LineStyle {
355
+ xml.color color
356
+ xml.width width
357
+ xml.GX_labelVisibility 1
358
+ }
359
+ }
360
+ end
361
+ end
362
+ end
@@ -0,0 +1,141 @@
1
+ #
2
+ # Copyright (c) 2013, Kalopa Research. All rights reserved. This is free
3
+ # software; you can redistribute it and/or modify it under the terms of the
4
+ # GNU General Public License as published by the Free Software Foundation;
5
+ # either version 2, or (at your option) any later version.
6
+ #
7
+ # It is distributed in the hope that it will be useful, but WITHOUT
8
+ # ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
9
+ # FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
10
+ # for more details.
11
+ #
12
+ # You should have received a copy of the GNU General Public License along
13
+ # with this product; see the file COPYING. If not, write to the Free
14
+ # Software Foundation, 675 Mass Ave, Cambridge, MA 02139, USA.
15
+ #
16
+ # THIS SOFTWARE IS PROVIDED BY KALOPA RESEARCH "AS IS" AND ANY EXPRESS OR
17
+ # IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES
18
+ # OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED.
19
+ # IN NO EVENT SHALL KALOPA RESEARCH BE LIABLE FOR ANY DIRECT, INDIRECT,
20
+ # INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING,
21
+ # BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF
22
+ # USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
23
+ # ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
24
+ # (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
25
+ # OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
26
+ #
27
+
28
+ ##
29
+ #
30
+ module SGS
31
+ class Navigate
32
+ attr_reader :mode
33
+
34
+ MODE_SLEEP = 0
35
+ MODE_TEST = 1
36
+ MODE_MANUAL = 2
37
+ MODE_UPDOWN = 3
38
+ MODE_OLYMPIC = 4
39
+ MODE_PRE_MISSION = 5
40
+ MODE_MISSION = 6
41
+ MODE_MISSION_END = 7
42
+ MODE_MISSION_ABORT = 8
43
+
44
+ MODENAMES = [
45
+ "Sleeping...",
46
+ "Test Mode",
47
+ "Manual Steering",
48
+ "Sail Up and Down",
49
+ "Sail a Triangle",
50
+ "Pre-Mission Wait",
51
+ "On Mission",
52
+ "Mission Ended",
53
+ "** Mission Abort **"
54
+ ].freeze
55
+
56
+ def initialize
57
+ @mode = MODE_SLEEP
58
+ @waypoint = nil
59
+ @curpos = nil
60
+ super
61
+ end
62
+
63
+ #
64
+ # What is the mode name?
65
+ def mode_name
66
+ MODENAMES[@mode]
67
+ end
68
+
69
+ def mode=(val)
70
+ puts "SETTING NEW MODE TO #{MODENAMES[val]}"
71
+ @mode = val
72
+ end
73
+
74
+ #
75
+ # This is the main navigator function. It does several things;
76
+ # 1. Look for the next waypoint and compute bearing and distance to it
77
+ # 2. Decide if we have reached the waypoint (and adjust accordingly)
78
+ # 3. Compute the boat heading (and adjust accordingly)
79
+ def run
80
+ puts "Navigator mode is #{mode_name}: Current Position:"
81
+ p curpos
82
+ p waypoint
83
+ case @mode
84
+ when MODE_UPDOWN
85
+ upwind_downwind_course
86
+ when MODE_OLYMPIC
87
+ olympic_course
88
+ when MODE_MISSION
89
+ mission
90
+ when MODE_MISSION_END
91
+ mission_end
92
+ when MODE_MISSION_ABORT
93
+ mission_abort
94
+ end
95
+ end
96
+
97
+ #
98
+ # Navigate a course up to a windward mark which is one nautical mile
99
+ # upwind of the start position. From there, navigate downwind to the
100
+ # finish position
101
+ def upwind_downwind_course
102
+ end
103
+
104
+ #
105
+ # Navigate around an olympic triangle. Sail one nautical mile upwind of
106
+ # the current position, then sail to a point to the left-side of the
107
+ # course which is at an angle of 120 degrees to the wind. From there,
108
+ # sail back to the start position
109
+ def olympic_course
110
+ end
111
+
112
+ #
113
+ # Navigate the mission. This is the main "meat and potatoes" navigation.
114
+ # It concerns itself with finding the best route to the next mark and
115
+ # sailing to that
116
+ def mission
117
+ end
118
+
119
+ #
120
+ # The mission has ended - sail to the rendezvous point
121
+ def mission_end
122
+ end
123
+
124
+ #
125
+ # The mission is aborted. Determine what to do next
126
+ def mission_abort
127
+ end
128
+
129
+ #
130
+ # What is our current position?
131
+ def curpos
132
+ @curpos ||= SGS::GPS.load
133
+ end
134
+
135
+ #
136
+ # What is the next waypoint?
137
+ def waypoint
138
+ @waypoint ||= SGS::Waypoint.load
139
+ end
140
+ end
141
+ end
data/lib/sgs/nmea.rb ADDED
@@ -0,0 +1,138 @@
1
+ #
2
+ # Copyright (c) 2013, Kalopa Research. All rights reserved. This is free
3
+ # software; you can redistribute it and/or modify it under the terms of the
4
+ # GNU General Public License as published by the Free Software Foundation;
5
+ # either version 2, or (at your option) any later version.
6
+ #
7
+ # It is distributed in the hope that it will be useful, but WITHOUT
8
+ # ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
9
+ # FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
10
+ # for more details.
11
+ #
12
+ # You should have received a copy of the GNU General Public License along
13
+ # with this product; see the file COPYING. If not, write to the Free
14
+ # Software Foundation, 675 Mass Ave, Cambridge, MA 02139, USA.
15
+ #
16
+ # THIS SOFTWARE IS PROVIDED BY KALOPA RESEARCH "AS IS" AND ANY EXPRESS OR
17
+ # IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES
18
+ # OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED.
19
+ # IN NO EVENT SHALL KALOPA RESEARCH BE LIABLE FOR ANY DIRECT, INDIRECT,
20
+ # INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING,
21
+ # BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF
22
+ # USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
23
+ # ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
24
+ # (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
25
+ # OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
26
+ #
27
+
28
+ ##
29
+ # Routines for handling sailboat navigation and route planning.
30
+ #
31
+ module SGS
32
+ class NMEA
33
+ ##
34
+ # Parse and create NMEA strings for various purposes.
35
+ #
36
+ attr_accessor :args, :valid, :checksum
37
+
38
+ def initialize
39
+ @args = Array.new
40
+ @valid = false
41
+ @checksum = 0
42
+ end
43
+
44
+ #
45
+ # Parse an NMEA string into its component parts.
46
+ def self.parse(str)
47
+ nmea = new
48
+ if nmea.parse(str) < 0
49
+ nmea = nil
50
+ end
51
+ nmea
52
+ end
53
+
54
+ #
55
+ # Parse an NMEA string into its component parts.
56
+ def parse(str)
57
+ str.chomp!
58
+ if str[0] != 36
59
+ return -1
60
+ end
61
+ str, sum = str[1..-1].split('*')
62
+ if sum.nil? or sum.to_i(16) != compute_csum(str)
63
+ return -1
64
+ end
65
+ @args = str.split(',')
66
+ @args.count
67
+ end
68
+
69
+ #
70
+ # Is the current line a GPRMC message?
71
+ def is_gprmc?
72
+ @args[0] == "GPRMC"
73
+ end
74
+
75
+ #
76
+ # Parse a GPRMC message
77
+ # ["GPRMC", "211321.000", "A", "5309.7743", "N", "00904.5576", "W", "0.17", "78.41", "200813", "", "", "A"]
78
+ def parse_gprmc
79
+ if @args.count < 12 or @args.count > 13
80
+ return nil
81
+ end
82
+ gps = SGS::GPS.new
83
+ gps.valid = @args[2] == "A"
84
+ hh = @args[1][0..1].to_i
85
+ mm = @args[1][2..3].to_i
86
+ ss = @args[1][4..-1].to_f
87
+ us = (ss % 1.0 * 1000000)
88
+ ss = ss.to_i
89
+ dd = @args[9][0..1].to_i
90
+ mn = @args[9][2..3].to_i
91
+ yy = @args[9][4..5].to_i + 2000
92
+ gps.time = Time.gm(yy, mn, dd, hh, mm, ss, us)
93
+ gps.location = Location.parse ll_nmea(@args[3,4]), ll_nmea(@args[5,6])
94
+ gps.sog = @args[7].to_f
95
+ gps.cmg = Bearing.degrees_to_radians @args[8].to_f
96
+ gps
97
+ end
98
+
99
+ #
100
+ # Output a GPRMC message
101
+ def make_gprmc(gps)
102
+ @valid = true
103
+ @args = Array.new
104
+ @args[0] = "GPRMC"
105
+ @args[1] = gps.time.strftime("%H%M%S.") + "%03d" % (gps.time.usec / 1000)
106
+ @args[2] = 'A'
107
+ @args.concat gps.location.latitude_array
108
+ @args.concat gps.location.longitude_array("%03d%07.4f")
109
+ @args[7] = "%.2f" % gps.sog
110
+ @args[8] = "%.2f" % Bearing.radians_to_degrees(gps.cmg)
111
+ @args[9] = gps.time.strftime("%d%m%y")
112
+ @args.concat ['', '']
113
+ @args << 'A'
114
+ end
115
+
116
+ #
117
+ # Convert an array of component parts into an NMEA string.
118
+ def to_s
119
+ str = @args.join(',')
120
+ "$%s*%02X" % [str, compute_csum(str)]
121
+ end
122
+
123
+ #
124
+ # Compute an NMEA checksum
125
+ def compute_csum(str)
126
+ @checksum = 0
127
+ str.each_byte {|ch| @checksum ^= ch}
128
+ @checksum
129
+ end
130
+
131
+ private
132
+ #
133
+ # Convert NMEA lat/long to something useful.
134
+ def ll_nmea(args)
135
+ args[0].gsub(/(\d\d\.)/, ' \1') + args[1]
136
+ end
137
+ end
138
+ end