sgslib 0.1.0 → 0.2.1

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA1:
3
- metadata.gz: 0fc3371c28ba4e23106c0a6ec7438269c4e127a4
4
- data.tar.gz: 513b37fe44496a798e3c9a332fa7446bd35d7a93
3
+ metadata.gz: a552ab707d5da785deb99f2b4118c2e3df9ebfe2
4
+ data.tar.gz: 36f82fa588da1882b67f32a8cff7709e4f4ca3c9
5
5
  SHA512:
6
- metadata.gz: cc840557ca82d76640c300cd2fb93745da198f5827444ff38afd84c2a11aabfd429dcfd600cc54ea30020c28acecd2a9b5719f820a8d277e8060e5ed97e20d73
7
- data.tar.gz: a574b15c37a867713b01cf4583edfafa4623461c433d30ad87512d47b507d76224abe8b91aaaefed6ad65d4a3e8d57ed46f1921ce8e461aebd41f5fa3a8979b9
6
+ metadata.gz: 1aec2712b93140cb27373d170dcbc5a9371ff7d451055752ca8c9c323d59080591f7012b790179c7abd07bd87dd9ec809b72033f374e172a25a54f67f88d74e8
7
+ data.tar.gz: 991805459fe5a66e8c2efc0be306a1d368abb1bedb76ab20bbfc73139aa4bfc0f7ca216844ede153e8529e5aebf6093fe99b4b91548db0dce501fb36d48eb6b9
data/lib/sgs/course.rb ADDED
@@ -0,0 +1,178 @@
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
+ # The code on this page was derived from formulae on the Movable Type site:
32
+ # http://www.movable-type.co.uk/scripts/latlong.html
33
+ #
34
+
35
+ require 'date'
36
+
37
+ module SGS
38
+ ##
39
+ #
40
+ # A class to handle the course sailed, as well as polar speed calculations.
41
+ # For speed calculations, it takes a range of polars as polynomials and
42
+ # then applies them.
43
+ class Course
44
+ attr_reader :awa, :speed
45
+ attr_writer :polar_curve
46
+
47
+ TACK_NAME = ["Starboard", "Port"].freeze
48
+ STARBOARD = 0
49
+ PORT = 1
50
+
51
+ #
52
+ # Right now, we have one polar - from a Catalina 22.
53
+ # Note that the speed is the same, regardless of the tack.
54
+ STANDARD = [
55
+ -3.15994,
56
+ 23.8741,
57
+ -27.4595,
58
+ 16.4868,
59
+ -5.15663,
60
+ 0.743936,
61
+ -0.0344716
62
+ ].freeze
63
+
64
+ #
65
+ # Set up the default values
66
+ def initialize(wind = nil)
67
+ @polar_curve = STANDARD
68
+ @awa = 0.0
69
+ @speed = 0.0
70
+ @wind = wind || Bearing.new(0.0, 10.0)
71
+ @heading = nil
72
+ self.heading = 0
73
+ end
74
+
75
+ #
76
+ # Return the current heading
77
+ def heading
78
+ @heading
79
+ end
80
+
81
+ #
82
+ # Return the heading in degrees
83
+ def heading_d
84
+ Bearing.rtod @heading
85
+ end
86
+
87
+ #
88
+ # Return the wind direction/speed
89
+ def wind
90
+ @wind
91
+ end
92
+
93
+ #
94
+ # Return the Apparent Wind Angle (AWA) in degrees
95
+ def awa_d
96
+ Bearing.rtod @awa
97
+ end
98
+
99
+ #
100
+ # Return the current tack
101
+ def tack
102
+ (@awa and @awa < 0.0) ? PORT : STARBOARD
103
+ end
104
+
105
+ #
106
+ # Return the tack name
107
+ def tack_name
108
+ TACK_NAME[tack]
109
+ end
110
+
111
+ #
112
+ # Set the heading
113
+ def heading=(new_heading)
114
+ return if @heading and @heading == new_heading
115
+ if new_heading > 2*Math::PI
116
+ new_heading -= 2*Math::PI
117
+ elsif new_heading < 0.0
118
+ new_heading += 2*Math::PI
119
+ end
120
+ @heading = new_heading
121
+ self.awa = @wind.angle - @heading
122
+ end
123
+
124
+ #
125
+ # Set the wind direction and recompute the AWA if appropriate. Note
126
+ # that we don't care about wind speed (for now)
127
+ def wind=(new_wind)
128
+ return if @wind and @wind.angle == new_wind.angle
129
+ @wind = new_wind
130
+ self.awa = @wind.angle - @heading
131
+ end
132
+
133
+ #
134
+ # Calculate the AWA based on our heading and wind direction
135
+ def awa=(new_awa)
136
+ if new_awa < -Math::PI
137
+ new_awa += 2*Math::PI
138
+ elsif new_awa > Math::PI
139
+ new_awa -= 2*Math::PI
140
+ end
141
+ return if @awa == new_awa
142
+ @awa = new_awa
143
+ compute_speed
144
+ end
145
+
146
+ #
147
+ # Compute a relative VMG based on the waypoint
148
+ def relative_vmg(waypt)
149
+ relvmg = @speed * Math::cos(waypt.bearing.angle - @heading) / waypt.distance
150
+ puts "Relative VMG to WPT: #{waypt.name} is #{relvmg}"
151
+ relvmg
152
+ end
153
+
154
+ #
155
+ # Compute the hull speed from the polar. This is just a guestimate of how
156
+ # fast the boat will travel at the particular apparent wind angle.
157
+ def compute_speed
158
+ awa = @awa.abs
159
+ return 0.0 if awa < 0.75
160
+ ap = 1.0
161
+ @speed = 0.0
162
+ @polar_curve.each do |poly_val|
163
+ @speed += poly_val * ap
164
+ ap *= awa
165
+ end
166
+ @speed /= 2.5 # Fudge for small boat
167
+ if @speed < 0.0
168
+ @speed = 0.0
169
+ end
170
+ end
171
+
172
+ #
173
+ # Convert to a string
174
+ def to_s
175
+ "Heading %dd (wind %.1f@%dd, AWA:%dd, speed=%.2fknots)" % [heading_d, wind.distance, wind.angle_d, awa_d, speed]
176
+ end
177
+ end
178
+ end
data/lib/sgs/location.rb CHANGED
@@ -32,6 +32,11 @@ require 'date'
32
32
  require 'json'
33
33
 
34
34
  module SGS
35
+ #
36
+ # Nominal radius of the planet, in nautical miles.
37
+ # http://en.wikipedia.org/wiki/Earth_radius#Mean_radii
38
+ EARTH_RADIUS = 3440.069528437724
39
+
35
40
  ##
36
41
  #
37
42
  # Class for dealing with latitude/longitude. Includes methods for parsing,
@@ -43,11 +48,6 @@ module SGS
43
48
  class Location
44
49
  attr_accessor :latitude, :longitude
45
50
 
46
- #
47
- # Nominal radius of the planet, in nautical miles.
48
- # http://en.wikipedia.org/wiki/Earth_radius#Mean_radii
49
- EARTH_RADIUS = 3440.069528437724
50
-
51
51
  #
52
52
  # Create the Location instance.
53
53
  def initialize(lat = nil, long = nil)
@@ -55,6 +55,13 @@ module SGS
55
55
  @longitude = long.to_f if long
56
56
  end
57
57
 
58
+ #
59
+ # The difference between two locations is a Bearing
60
+ def -(loc)
61
+ puts "Distance from #{self} to #{loc}"
62
+ Bearing.compute(self, loc)
63
+ end
64
+
58
65
  #
59
66
  # Calculate a new position from the current position
60
67
  # given a bearing (angle and distance)
@@ -66,18 +73,18 @@ module SGS
66
73
  # Math.cos(lat1)*Math.sin(d/R)*Math.cos(angle) );
67
74
  # var lon2 = lon1 + Math.atan2(Math.sin(angle)*Math.sin(d/R)*Math.cos(lat1),
68
75
  # Math.cos(d/R)-Math.sin(lat1)*Math.sin(lat2));
69
- def calculate(bearing)
76
+ def +(bearing)
70
77
  loc = Location.new
71
78
  sin_angle = Math.sin(bearing.angle)
72
79
  cos_angle = Math.cos(bearing.angle)
73
- sin_dstr = Math.sin(bearing.distance / EARTH_RADIUS)
74
- cos_dstr = Math.cos(bearing.distance / EARTH_RADIUS)
80
+ sin_dstr = Math.sin(bearing.distance / SGS::EARTH_RADIUS)
81
+ cos_dstr = Math.cos(bearing.distance / SGS::EARTH_RADIUS)
75
82
  sin_lat1 = Math.sin(@latitude)
76
83
  cos_lat1 = Math.cos(@latitude)
77
84
  loc.latitude = Math.asin(sin_lat1*cos_dstr + cos_lat1*sin_dstr*cos_angle)
78
- sin_lat2 = Math.sin(loc.latitude)
85
+ sin_lat2 = Math.sin(@latitude)
79
86
  loc.longitude = @longitude + Math.atan2(sin_angle*sin_dstr*cos_lat1,
80
- cos_dstr - sin_lat1*sin_lat2)
87
+ cos_dstr - sin_lat1*sin_lat2)
81
88
  loc
82
89
  end
83
90
 
@@ -139,42 +146,42 @@ module SGS
139
146
 
140
147
  #
141
148
  # Display the lat/long as it would appear in a KML file.
142
- def to_kml(sep = ' ')
149
+ def to_kml(sep = ',')
143
150
  vals = [@longitude, @latitude, 0.0]
144
- str_vals = vals.map {|val| "%.6f" % Bearing.radians_to_degrees(val)}
151
+ str_vals = vals.map {|val| "%.8f" % Bearing.rtod(val)}
145
152
  str_vals.join(sep)
146
153
  end
147
154
 
148
155
  #
149
156
  # Helper functions for working in degrees.
150
- def latitude_degrees
151
- Bearing.radians_to_degrees @latitude
157
+ def latitude_d
158
+ Bearing.rtod @latitude
152
159
  end
153
160
 
154
- def latitude_degrees=(val)
155
- @latitude = Bearing.degrees_to_radians val
161
+ def latitude_d=(val)
162
+ @latitude = Bearing.dtor val
156
163
  end
157
164
 
158
165
  def latitude_array(fmt = nil)
159
- make_ll_array latitude_degrees, "NS", fmt
166
+ make_ll_array latitude_d, "NS", fmt
160
167
  end
161
168
 
162
- def longitude_degrees
163
- Bearing.radians_to_degrees @longitude
169
+ def longitude_d
170
+ Bearing.rtod @longitude
164
171
  end
165
172
 
166
- def longitude_degrees=(val)
167
- @longitude = Bearing.degrees_to_radians val
173
+ def longitude_d=(val)
174
+ @longitude = Bearing.dtor val
168
175
  end
169
176
 
170
177
  def longitude_array(fmt = nil)
171
- make_ll_array longitude_degrees, "EW", fmt
178
+ make_ll_array longitude_d, "EW", fmt
172
179
  end
173
180
 
174
181
  #
175
182
  # Subtract one location from another, returning a bearing
176
183
  def -(loc)
177
- Bearing.calculate(self, loc)
184
+ Bearing.compute(self, loc)
178
185
  end
179
186
 
180
187
  private
@@ -186,7 +193,7 @@ module SGS
186
193
  val = args.shift
187
194
  val = val + args.shift / 60.0 if args.length > 0
188
195
  val = val + args.shift / 3600.0 if args.length > 0
189
- Bearing.degrees_to_radians val * ((nsew.index(dir) == 1) ? -1 : 1)
196
+ Bearing.dtor val * ((nsew.index(dir) == 1) ? -1 : 1)
190
197
  end
191
198
 
192
199
  #
@@ -198,7 +205,7 @@ module SGS
198
205
  else
199
206
  chr = str[0]
200
207
  end
201
- "%8.6f%c" % [Bearing.radians_to_degrees(val), chr]
208
+ "%8.6f%c" % [Bearing.rtod(val), chr]
202
209
  end
203
210
 
204
211
  #
@@ -235,18 +242,18 @@ module SGS
235
242
  #
236
243
  # Create a bearing from an angle in degrees.
237
244
  def self.degrees(angle, distance)
238
- new(Bearing.degrees_to_radians(angle), distance)
245
+ new(Bearing.dtor(angle), distance)
239
246
  end
240
247
 
241
248
  #
242
249
  # Handy function to translate degrees to radians
243
- def self.degrees_to_radians(deg)
250
+ def self.dtor(deg)
244
251
  deg.to_f * Math::PI / 180.0
245
252
  end
246
253
 
247
254
  #
248
255
  # Handy function to translate radians to degrees
249
- def self.radians_to_degrees(rad)
256
+ def self.rtod(rad)
250
257
  rad.to_f * 180.0 / Math::PI
251
258
  end
252
259
 
@@ -259,7 +266,7 @@ module SGS
259
266
  #
260
267
  # Another handy function to re-adjust an angle (in degrees) away from
261
268
  # negative.
262
- def self.absolute_degrees(angle)
269
+ def self.absolute_d(angle)
263
270
  (angle + 360) % 360
264
271
  end
265
272
 
@@ -279,7 +286,7 @@ module SGS
279
286
  # var x = Math.cos(lat1)*Math.sin(lat2) -
280
287
  # Math.sin(lat1)*Math.cos(lat2)*Math.cos(dLon);
281
288
  # var angle = Math.atan2(y, x).toDeg();
282
- def self.calculate(loc1, loc2)
289
+ def self.compute(loc1, loc2)
283
290
  bearing = new
284
291
  sin_lat1 = Math.sin(loc1.latitude)
285
292
  sin_lat2 = Math.sin(loc2.latitude)
@@ -288,7 +295,7 @@ module SGS
288
295
  sin_dlon = Math.sin(loc2.longitude - loc1.longitude)
289
296
  cos_dlon = Math.cos(loc2.longitude - loc1.longitude)
290
297
  bearing.distance = Math.acos(sin_lat1*sin_lat2 + cos_lat1*cos_lat2*cos_dlon) *
291
- Location::EARTH_RADIUS
298
+ SGS::EARTH_RADIUS
292
299
  y = sin_dlon * cos_lat2
293
300
  x = cos_lat1 * sin_lat2 - sin_lat1 * cos_lat2 * cos_dlon
294
301
  bearing.angle = Math.atan2(y, x)
@@ -309,8 +316,8 @@ module SGS
309
316
 
310
317
  #
311
318
  # Return the angle (in degrees)
312
- def angle_degrees
313
- Bearing.radians_to_degrees(@angle).to_i
319
+ def angle_d
320
+ Bearing.rtod(@angle).to_i
314
321
  end
315
322
 
316
323
  #
@@ -322,7 +329,7 @@ module SGS
322
329
  #
323
330
  # Convert to a string
324
331
  def to_s
325
- "BRNG %03dd,%.3fnm" % [angle_degrees, @distance]
332
+ "BRNG %03dd,%.3fnm" % [angle_d, @distance]
326
333
  end
327
334
  end
328
335
  end
data/lib/sgs/mission.rb CHANGED
@@ -29,319 +29,328 @@
29
29
  # Routines for handling sailboat navigation and route planning.
30
30
  #
31
31
  require 'date'
32
+ require 'nokogiri'
32
33
 
33
34
  module SGS
34
35
  #
35
36
  # Handle a specific mission.
36
37
  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
38
+ attr_accessor :attractors, :repellors, :track
39
+ attr_accessor :where, :time, :course, :distance
41
40
 
42
41
  #
43
- # Create the attractors and repellors
44
- def initialize(id = 0)
45
- @root_dir = "."
46
- @waypoints = []
42
+ # Create the attractors and repellors as well as the track array
43
+ # and other items. @where is our current TrackPoint, @current_wpt is
44
+ # the waypoint we're working (-1 if none), @course is the heading/speed
45
+ # the boat is on.
46
+ def initialize
47
+ @attractors = []
47
48
  @repellors = []
48
- @track = []
49
- @id = id
50
- @time = DateTime.now
51
- @wpt_index = -1
52
- @heading = -1
53
- @course = -1
54
- @tack = 0
49
+ @track = nil
50
+ @current_wpt = -1
51
+ @start_time = @time = nil
52
+ @where = nil
53
+ @course = Course.new
54
+ @distance = 0
55
+ @swing = 60
55
56
  end
56
57
 
57
58
  #
58
59
  # Load a new mission from the missions directory.
59
- def self.load(id)
60
+ def self.load(filename)
60
61
  mission = new
61
- mission.id = id
62
- mission.read(File.open(mission.filename))
62
+ mission.read(File.open(filename))
63
63
  mission
64
64
  end
65
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
66
  #
113
67
  # Commence a mission...
114
68
  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
69
+ @start_time = @time = time || Time.now
70
+ @track = [TrackPoint.new(time, @where)]
71
+ @current_wpt = 0
121
72
  end
122
73
 
123
74
  #
124
75
  # Terminate a mission.
125
76
  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
77
+ puts "***** Mission terminated! *****"
78
+ @current_wpt = -1
134
79
  end
135
80
 
136
81
  #
137
- # Set our current location (and timestamp).
138
- def set_location(where, time = nil)
139
- @where = where
140
- @time = time || DateTime.now
82
+ # Compute the best heading based on our current position and the position
83
+ # of the current attractor. This is where the heavy-lifting happens
84
+ def navigate
85
+ return unless active?
86
+ puts "Attempting to navigate to #{waypoint}"
141
87
  #
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?
88
+ # First off, compute distance and bearing from our current location
89
+ # to every attractor and repellor.
90
+ @attractors[@current_wpt..-1].each do |waypt|
91
+ waypt.compute_bearing(@where)
92
+ puts "Angle: #{waypt.bearing.angle_d}, Distance: #{waypt.bearing.distance} (adj:#{waypt.distance})"
93
+ end
94
+ @repellors.each do |waypt|
95
+ waypt.compute_bearing(@where)
96
+ puts "Angle: #{waypt.bearing.angle_d}, Distance: #{waypt.bearing.distance} (adj:#{waypt.distance})"
97
+ end
158
98
  #
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
99
+ # Right. Now look to see if we've achieved the current waypoint and
100
+ # adjust, accordingly
101
+ while active? and reached? do
102
+ next_waypoint!
167
103
  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
104
+ return unless active?
105
+ puts "Angle to next waypoint: #{waypoint.bearing.angle_d}d"
106
+ puts "Adjusted distance to waypoint is #{@distance}"
188
107
  #
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))
108
+ # Now, start the vector field analysis by examining headings either side
109
+ # of the bearing to the waypoint.
110
+ best_course = @course
111
+ best_relvmg = 0.0
112
+ puts "Currently on a #{@course.tack_name} tack (heading is #{@course.heading_d} degrees)"
113
+ (-@swing..@swing).each do |alpha_d|
114
+ puts ">> Computing swing of #{alpha_d} degrees"
115
+ new_course = Course.new(@course.wind)
116
+ new_course.heading = waypoint.bearing.angle + Bearing.dtor(alpha_d)
206
117
  #
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]
118
+ # Ignore head-to-wind cases, as they're pointless.
119
+ next if new_course.speed < 0.001
120
+ puts "AWA:#{new_course.awa_d}, heading:#{new_course.heading_d}, speed:#{new_course.speed}"
121
+ relvmg = 0.0
122
+ relvmg = new_course.relative_vmg(@attractors[@current_wpt])
123
+ @attractors[@current_wpt..-1].each do |waypt|
124
+ relvmg += new_course.relative_vmg(waypt)
212
125
  end
213
- if pvmg > max_pvmg
214
- max_pvmg = pvmg
215
- ideal_twa = twa
216
- ideal_vmg = vmg
126
+ @repellors.each do |waypt|
127
+ relvmg -= new_course.relative_vmg(waypt)
128
+ end
129
+ relvmg *= 0.1 if new_course.tack != @course.tack
130
+ puts "Relative VMG: #{relvmg}"
131
+ if relvmg > best_relvmg
132
+ puts "Best heading (so far)"
133
+ best_relvmg = relvmg
134
+ best_course = new_course
217
135
  end
218
136
  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
137
+ puts "Best RELVMG: #{best_relvmg}"
138
+ puts "TACKING!" if best_course.tack != @course.tack
139
+ puts "New HDG: #{best_course.heading_d} (AWA:#{best_course.awa_d}), WPT:#{waypoint.name}"
140
+ @course = best_course
235
141
  end
236
142
 
237
143
  #
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?
144
+ # Set new position
145
+ def set_position(time, loc)
146
+ @where = loc
147
+ @time = time
148
+ @track << TrackPoint.new(@time, @where)
243
149
  end
244
150
 
245
151
  #
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
152
+ # Advance the mission by a number of seconds (computing the new location
153
+ # in the process). Fake out the speed and thus the location.
154
+ def simulated_movement(how_long = 60)
155
+ puts "Advancing mission by #{how_long}s"
156
+ distance = @course.speed * how_long.to_f / 3600.0
157
+ puts "Travelled #{distance * 1852.0} metres in that time."
158
+ set_position(@time + how_long, @where + Bearing.new(@course.heading, distance))
159
+ end
160
+
161
+ #
162
+ # On-mission means we have something to do. In other words, we have a
163
+ # waypoint to get to.
164
+ def active?
165
+ @current_wpt >= 0 and @current_wpt < @attractors.count
250
166
  end
251
167
 
252
168
  #
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
169
+ # How long has the mission been active?
170
+ def elapsed
171
+ @time - @start_time
258
172
  end
259
173
 
260
174
  #
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
175
+ # Return the current waypoint.
176
+ def waypoint
177
+ active? ? @attractors[@current_wpt] : nil
266
178
  end
267
179
 
268
180
  #
269
- # Do we need to tack?
270
- def tacking?
271
- hnt = @have_new_tack
272
- @have_new_tack = false
273
- active? and hnt
181
+ # Have we reached the waypoint? Note that even though the waypoints have
182
+ # a "reached" circle, we discard the last 10m on the basis that it is
183
+ # within the GPS error.
184
+ def reached?
185
+ @distance = @attractors[@current_wpt].distance
186
+ puts "ARE WE THERE YET? (dist=#{@distance})"
187
+ return true if @distance <= 0.0027
188
+ #
189
+ # Check to see if the next WPT is nearer than the current one
190
+ #if @current_wpt < (@attractors.count - 1)
191
+ # next_wpt = @attractors[@current_wpt + 1]
192
+ # brng = @attractors[@current_wpt].location - next_wpt.location
193
+ # angle = Bearing.absolute(waypoint.bearing.angle - next_wpt.bearing.angle)
194
+ # return true if brng.distance > next_wpt.distance and
195
+ # angle > (0.25 * Math::PI) and
196
+ # angle < (0.75 * Math::PI)
197
+ #end
198
+ puts "... Sadly, no."
199
+ return false
274
200
  end
275
201
 
276
202
  #
277
- # Return the next (current) waypoint.
278
- def waypoint
279
- active? ? @waypoints[@wpt_index] : nil
203
+ # Advance to the next waypoint. Return TRUE if
204
+ # there actually is one...
205
+ def next_waypoint!
206
+ raise "No mission currently active" unless active?
207
+ @current_wpt += 1
208
+ puts "Attempting to navigate to #{waypoint.name}" if active?
280
209
  end
210
+
281
211
  #
282
212
  # Return the mission status as a string
283
213
  def status_str
284
- mins = (@time - @start_time) * 24.0 * 60.0
285
- hours = (mins / 60.0).to_i
214
+ mins = elapsed / 60
215
+ hours = mins / 60
216
+ mins %= 60
286
217
  days = hours / 24
287
- mins = (mins % 60.0).to_i
288
218
  hours %= 24
289
- str = ">>>#{@time}, "
219
+ str = ">>> #{@time}, "
290
220
  if days < 1
291
- str += "%dh%dm" % [hours, mins]
221
+ str += "%dh%02dm" % [hours, mins]
292
222
  else
293
- str += "%dd%dh" % [days, mins]
223
+ str += "+%dd%%02dh%02dm" % [days, hours, mins]
294
224
  end
295
225
  str + ": My position is #{@where}"
296
226
  end
297
227
 
298
228
  #
299
- # Name/path of mission file.
300
- def filename
301
- @root_dir + "/missions/%03d" % id
229
+ # Compute the remaining distance from the current location
230
+ def overall_distance
231
+ start_wpt = active? ? @current_wpt : 0
232
+ dist = 0.0
233
+ loc = @where
234
+ @attractors[start_wpt..-1].each do |wpt|
235
+ wpt.compute_bearing(loc)
236
+ dist += wpt.bearing.distance
237
+ loc = wpt.location
238
+ end
239
+ dist
240
+ end
241
+
242
+ #
243
+ # Parse a mission file.
244
+ def read(file)
245
+ file.each do |line|
246
+ unless line =~ /^#/
247
+ args = line.split(':')
248
+ code = args[0]
249
+ loc = Location.parse_str(args[1])
250
+ nrml = Bearing.dtor args[2].to_i
251
+ dist = args[3].to_f
252
+ name = args[4].chomp
253
+ case code
254
+ when /[Xx]/
255
+ @where = loc
256
+ @course.wind = Bearing.new(nrml, dist)
257
+ when /\d/
258
+ @attractors[code.to_i] = Waypoint.new(loc, nrml, dist, name)
259
+ when /[Rr]/
260
+ @repellors << Waypoint.new(loc, nrml, dist, name, true)
261
+ end
262
+ end
263
+ end
264
+ @current_wpt = -1
302
265
  end
303
266
 
304
267
  #
305
- # Save the track.
306
- def track_save
307
- kml_write(File.open(track_filename, 'w'))
268
+ # Write a mission to a file.
269
+ def write(filename)
270
+ File.open(filename, 'w') do |file|
271
+ file.puts "#\n# My starting position."
272
+ file.puts ["X", @where.to_s, @course.wind.angle_d.to_i, @course.wind.distance.to_i, "Starting position"].join(':')
273
+ file.puts "#\n# Attractors."
274
+ @attractors.each_with_index do |wpt, i|
275
+ file.puts "%d:%s:%d:%f:%s" % [i,
276
+ wpt.location,
277
+ wpt.normal_d,
278
+ wpt.radius,
279
+ wpt.name]
280
+ end
281
+ file.puts "#\n# Repellors."
282
+ @repellors.each do |wpt|
283
+ file.puts "r:%s:%d:%f:%s" % [wpt.location,
284
+ wpt.normal_d,
285
+ wpt.radius,
286
+ wpt.name]
287
+ end
288
+ end
308
289
  end
309
290
 
310
291
  #
311
- # The name of the track file
312
- def track_filename
313
- @root_dir + "/tracks/output-%03d.kml" % id
292
+ # Save the track.
293
+ def track_save(filename)
294
+ kml_write(File.open(filename, 'w'))
314
295
  end
315
296
 
316
297
  #
317
298
  # Write the track data as a KML file.
299
+ # xml.LineString {
300
+ # xml.extrude 1
301
+ # xml.tessellate 1
302
+ # xml.coordinates wpt.to_kml
303
+ # }
304
+ # }
305
+ # xml.Placemark {
306
+ # xml.styleUrl "#attractorLine"
307
+ # xml.LineString {
308
+ # xml.extrude 1
309
+ # xml.tessellate 1
310
+ # xml.coordinates wpt.to_axis_kml
311
+ # }
312
+ # }
318
313
  def kml_write(file)
319
314
  builder = Nokogiri::XML::Builder.new do |xml|
320
315
  xml.kml('xmlns' => 'http://www.opengis.net/kml/2.2',
321
316
  'xmlns:gx' => 'http://www.google.com/kml/ext/2.2') {
322
317
  xml.Folder {
323
- xml_line_style(xml, "waypointLine", "0xffcf0000", 4)
318
+ xml_line_style(xml, "attractorLine", "0xffcf0000", 4)
324
319
  xml_line_style(xml, "repellorLine", "0xff00007f", 4)
325
320
  xml_line_style(xml, "trackLine")
326
- @waypoints.each do |wpt|
321
+ @attractors.each do |wpt|
327
322
  xml.Placemark {
328
323
  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
324
+ xml.styleUrl "#attractorLine"
325
+ xml.Point {
326
+ xml.coordinates wpt.location.to_kml
334
327
  }
335
328
  }
329
+ end
330
+ @repellors.each do |wpt|
336
331
  xml.Placemark {
337
- xml.styleUrl "#waypointLine"
338
- xml.LineString {
339
- xml.extrude 1
340
- xml.tessellate 1
341
- xml.coordinates wpt.to_axis_kml
332
+ xml.name wpt.name
333
+ xml.styleUrl "#repellorLine"
334
+ xml.Point {
335
+ xml.coordinates wpt.location.to_kml
342
336
  }
343
337
  }
344
338
  end
339
+ xml.Placemark {
340
+ xml.name "Track"
341
+ xml.styleUrl "#trackLine"
342
+ xml.GX_Track {
343
+ @track.each do |pt|
344
+ xml.when pt.time.strftime('%Y-%m-%dT%H:%M:%S+00:00')
345
+ end
346
+ @track.each do |pt|
347
+ xml.GX_coord pt.location.to_kml(' ')
348
+ end
349
+ }
350
+ }
351
+ }
352
+ }
353
+ end
345
354
  # Requires a hack to get rid of the 'gx:' for the when tag.
346
355
  file.puts builder.to_xml.gsub(/GX_/, 'gx:')
347
356
  end
data/lib/sgs/nmea.rb CHANGED
@@ -55,7 +55,7 @@ module SGS
55
55
  # Parse an NMEA string into its component parts.
56
56
  def parse(str)
57
57
  str.chomp!
58
- if str[0] != 36
58
+ if str[0] != "$"
59
59
  return -1
60
60
  end
61
61
  str, sum = str[1..-1].split('*')
@@ -80,7 +80,7 @@ module SGS
80
80
  return nil
81
81
  end
82
82
  gps = SGS::GPS.new
83
- gps.valid = @args[2] == "A"
83
+ gps.is_valid if @args[2] == "A"
84
84
  hh = @args[1][0..1].to_i
85
85
  mm = @args[1][2..3].to_i
86
86
  ss = @args[1][4..-1].to_f
@@ -92,7 +92,7 @@ module SGS
92
92
  gps.time = Time.gm(yy, mn, dd, hh, mm, ss, us)
93
93
  gps.location = Location.parse ll_nmea(@args[3,4]), ll_nmea(@args[5,6])
94
94
  gps.sog = @args[7].to_f
95
- gps.cmg = Bearing.degrees_to_radians @args[8].to_f
95
+ gps.cmg = Bearing.dtor @args[8].to_f
96
96
  gps
97
97
  end
98
98
 
@@ -107,7 +107,7 @@ module SGS
107
107
  @args.concat gps.location.latitude_array
108
108
  @args.concat gps.location.longitude_array("%03d%07.4f")
109
109
  @args[7] = "%.2f" % gps.sog
110
- @args[8] = "%.2f" % Bearing.radians_to_degrees(gps.cmg)
110
+ @args[8] = "%.2f" % Bearing.radians_to_d(gps.cmg)
111
111
  @args[9] = gps.time.strftime("%d%m%y")
112
112
  @args.concat ['', '']
113
113
  @args << 'A'
@@ -139,6 +139,7 @@ module SGS
139
139
  end
140
140
  $redis.incr count_name
141
141
  end
142
+ true
142
143
  end
143
144
 
144
145
  #
data/lib/sgs/version.rb CHANGED
@@ -1,3 +1,3 @@
1
1
  module SGS
2
- VERSION = "0.1.0"
2
+ VERSION = "0.2.1"
3
3
  end
data/lib/sgs/waypoint.rb CHANGED
@@ -34,82 +34,113 @@ module SGS
34
34
  #
35
35
  # Waypoint, Attractor, and Repellor definitions
36
36
  class Waypoint < RedisBase
37
- attr_accessor :location, :chord, :type, :name, :bearing, :distance
37
+ attr_accessor :location, :normal, :radius, :name, :repellor, :bearing
38
+ attr_reader :bearing, :distance
38
39
 
39
40
  #
40
- # Types of waypoints
41
- START_LINE = 0
42
- FINISH_LINE = 1
43
- WAYPOINT = 2
44
- REPELLOR = 3
45
-
46
- def initialize(location = nil, chord = nil, name = "", type = WAYPOINT)
41
+ # Define a new Attractor or Repellor, based on certain parameters.
42
+ # The location is the centre of the waypoint. The normal is the compass
43
+ # angle of the start of the semicircle, and the radius is the size of
44
+ # the arc. You can specify an optional name for the waypoint and also
45
+ # indicate if we should be attracted or repelled by it.
46
+ def initialize(location = nil, normal = 0.0, radius = 0.1, name = "", repellor = false)
47
47
  @location = location || Location.new
48
- @chord = chord
49
- @type = type
48
+ @normal = normal
49
+ @radius = radius
50
50
  @name = name
51
+ @repellor = repellor
52
+ @bearing = nil
53
+ @distance = 0
51
54
  end
52
55
 
53
56
  #
54
- # Calculate the back-vector from the waypoint to the specified position
55
- # Calculate the adjusted distance between the mark and the set location.
57
+ # Calculate the back-vector from the waypoint to the specified position.
58
+ # Calculate the adjusted distance between the position and the mark.
56
59
  # Check to see if our back-bearing from the waypoint to our location is
57
- # inside the chord of the waypoint. If so, reduce the distance to the
58
- # waypoint by the length of the chord.
60
+ # inside the chord of the waypoint, which is a semicircle commencing at
61
+ # the normal. If so, reduce the distance to the waypoint by the length
62
+ # of the chord. @distance is the adjusted distance to the location
59
63
  def compute_bearing(loc)
60
- @bearing = Bearing.calculate(loc, @location)
64
+ @bearing = loc - @location
61
65
  @distance = @bearing.distance
62
- d = Bearing.new(@bearing.back_angle - @chord.angle, @distance)
63
- # A chord angle of 0 gives a semicircle from 180 to 360 degrees
64
- # (inclusive). If our approach angle is within range, then reduce our
65
- # distance to the mark by the chord distance (radius).
66
- unless d.angle > 0.0 and d.angle < Math::PI
67
- @distance -= @chord.distance
68
- end
66
+ d = Bearing.new(@bearing.back_angle - @normal, @bearing.distance)
67
+ # A chord angle of 0 gives a semicircle from 0 to 180 degrees. If our
68
+ # approach angle is within range, then reduce our distance to the mark
69
+ # by the chord distance (radius).
70
+ @distance -= @radius if d.angle >= 0.0 and d.angle < Math::PI
71
+ @distance = 0.0 if @distance < 0.0
69
72
  @distance
70
73
  end
71
74
 
75
+ #
76
+ # Is this an attractor?
77
+ def attractor?
78
+ @repellor == false
79
+ end
80
+
81
+ #
82
+ # Is this a repellor?
83
+ def repellor?
84
+ @repellor == true
85
+ end
86
+
72
87
  #
73
88
  # Is the waypoint in scope? In other words, is our angle inside the chord.
74
89
  def in_scope?
75
90
  puts "In-scope distance is %f..." % @distance
76
- @distance <= 0.0
91
+ @distance == 0.0
92
+ end
93
+
94
+ #
95
+ # Convert the waypoint normal to/from degrees
96
+ def normal_d
97
+ Bearing.rtod @normal
98
+ end
99
+
100
+ #
101
+ # Convert the waypoint normal to/from degrees
102
+ def normal_d=(val)
103
+ @normal = Bearing.dtor val
77
104
  end
78
105
 
79
106
  #
80
107
  # Pretty version of the waypoint.
81
108
  def to_s
82
- "'#{@name}' at #{@location} => #{chord}"
109
+ "'#{@name}' at #{@location} => #{normal_d}%#{@radius}"
83
110
  end
84
111
 
85
112
  #
86
113
  # Display a string for a KML file
87
114
  def to_kml
88
- c2 = @chord.clone
89
- c2.angle += Math::PI
90
- pos1 = @location.calculate(@chord)
91
- pos2 = @location.calculate(c2)
92
- "#{pos1.to_kml(',')} #{pos2.to_kml(',')}"
115
+ puts "TO KML!"
116
+ #p self
117
+ #c2 = @chord.clone
118
+ #c2.angle += Math::PI
119
+ #pos1 = @location.calculate(@chord)
120
+ #pos2 = @location.calculate(c2)
121
+ #"#{pos1.to_kml(',')} #{pos2.to_kml(',')}"
93
122
  end
94
123
 
95
124
  #
96
125
  # Show the axis line for the waypoint (as a KML)
97
126
  def to_axis_kml
98
- c2 = @chord.clone
99
- c2.angle += 1.5 * Math::PI
100
- pos1 = @location.calculate(c2)
101
- "#{@location.to_kml(',')} #{pos1.to_kml(',')}"
127
+ puts "TO_AXIS_KML!"
128
+ #::FIXME::
129
+ #c2 = @chord.clone
130
+ #c2.angle += 1.5 * Math::PI
131
+ #pos1 = @location.calculate(c2)
132
+ #"#{@location.to_kml(',')} #{pos1.to_kml(',')}"
102
133
  end
103
134
  end
104
135
 
105
136
  #
106
- # Store the track over the ground.
107
- class Track
137
+ # Store an individual track point.
138
+ class TrackPoint
108
139
  attr_accessor :time, :location
109
140
 
110
- def initialize(time, location)
111
- @time = time
112
- @location = location
141
+ def initialize(time = nil, location = nil)
142
+ @time = time ? time.clone : nil
143
+ @location = location ? location.clone : nil
113
144
  end
114
145
  end
115
146
  end
data/lib/sgslib.rb CHANGED
@@ -33,13 +33,13 @@ require 'sgs/location'
33
33
  require 'sgs/nmea'
34
34
  require 'sgs/gps'
35
35
  require 'sgs/waypoint'
36
- require 'sgs/polar'
37
36
  require 'sgs/alarm'
38
37
  require 'sgs/timing'
39
38
  require 'sgs/command'
40
39
  require 'sgs/otto'
40
+ require 'sgs/course'
41
41
  require 'sgs/navigate'
42
- #require 'mission'
42
+ require 'sgs/mission'
43
43
 
44
44
  module SGS
45
45
  # Your code goes here...
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: sgslib
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Dermot Tynan
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2017-03-22 00:00:00.000000000 Z
11
+ date: 2018-06-21 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: bundler
@@ -86,6 +86,7 @@ files:
86
86
  - bin/setup
87
87
  - lib/sgs/alarm.rb
88
88
  - lib/sgs/command.rb
89
+ - lib/sgs/course.rb
89
90
  - lib/sgs/gps.rb
90
91
  - lib/sgs/location.rb
91
92
  - lib/sgs/logger.rb
@@ -93,7 +94,6 @@ files:
93
94
  - lib/sgs/navigate.rb
94
95
  - lib/sgs/nmea.rb
95
96
  - lib/sgs/otto.rb
96
- - lib/sgs/polar.rb
97
97
  - lib/sgs/redis_base.rb
98
98
  - lib/sgs/setup.rb
99
99
  - lib/sgs/timing.rb
@@ -122,7 +122,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
122
122
  version: '0'
123
123
  requirements: []
124
124
  rubyforge_project:
125
- rubygems_version: 2.5.1
125
+ rubygems_version: 2.5.2.1
126
126
  signing_key:
127
127
  specification_version: 4
128
128
  summary: Sailboat Guidance System
data/lib/sgs/polar.rb DELETED
@@ -1,86 +0,0 @@
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
- # The code on this page was derived from formulae on the Movable Type site:
32
- # http://www.movable-type.co.uk/scripts/latlong.html
33
- #
34
-
35
- require 'date'
36
-
37
- module SGS
38
- ##
39
- #
40
- # A class to handle boat polar calculations. It takes a range of polars
41
- # as polynomials and then applies them.
42
- class Polar
43
- # Right now, we have one polar - from a Catalina 22.
44
- # Note that the speed is the same, regardless of the tack.
45
- STANDARD = [
46
- -3.15994,
47
- 23.8741,
48
- -27.4595,
49
- 16.4868,
50
- -5.15663,
51
- 0.743936,
52
- -0.0344716
53
- ].freeze
54
-
55
- #
56
- # set up the default values
57
- def initialize
58
- @curve = STANDARD
59
- end
60
-
61
- #
62
- # Compute the hull speed from the polar
63
- # :awa: is the apparent wind angle (in radians)
64
- # :wspeed: is the wind speed (in knots)
65
- def speed(awa, wspeed = 0.0)
66
- awa = awa.to_f.abs
67
- ap = 1.0
68
- @speed = 0.0
69
- @curve.each do |poly_val|
70
- @speed += poly_val * ap
71
- ap *= awa
72
- end
73
- @speed /= 1.529955
74
- if @speed < 0.0
75
- @speed = 0.0
76
- end
77
- @speed
78
- end
79
-
80
- #
81
- # Calculate the VMG from the angle to the mark.
82
- def vmg(alpha)
83
- Math::cos(alpha) * @speed
84
- end
85
- end
86
- end