balanced_vrp_clustering 0.4.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,39 @@
1
+ # Copyright © Cartoway
2
+ #
3
+ # This file is part of balanced_vrp_clustering.
4
+ #
5
+ # Cartoway balanced_vrp_clustering is free software. You can redistribute it and/or
6
+ # modify since you respect the terms of the GNU Affero General
7
+ # Public License as published by the Free Software Foundation,
8
+ # either version 3 of the License, or (at your option) any later version.
9
+ #
10
+ # Cartoway Planner is distributed in the hope that it will be useful, but WITHOUT
11
+ # ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
12
+ # or FITNESS FOR A PARTICULAR PURPOSE. See the Licenses for more details.
13
+ #
14
+ # You should have received a copy of the GNU Affero General Public License
15
+ # along with Cartoway Planner. If not, see:
16
+ # <http://www.gnu.org/licenses/agpl.html>
17
+ #
18
+
19
+ # frozen_string_literal: true
20
+
21
+ require 'rbconfig'
22
+
23
+ ROOT = File.expand_path('..', __dir__)
24
+ NATIVE_CRATE_DIR = File.join(ROOT, 'rust', 'balanced_vrp_clustering_native')
25
+ LIB_DIR = File.join(ROOT, 'lib')
26
+ DLEXT = RbConfig::CONFIG['DLEXT']
27
+ SO_NAME = "balanced_vrp_clustering_native.#{DLEXT}"
28
+
29
+ # After gem install, rb_sys places the shared lib next to extconf (rust/.../crate).
30
+ SEARCH_PATHS = [
31
+ File.join(LIB_DIR, SO_NAME),
32
+ File.join(NATIVE_CRATE_DIR, SO_NAME),
33
+ File.join(NATIVE_CRATE_DIR, 'target', 'release', SO_NAME)
34
+ ].freeze
35
+
36
+ path = SEARCH_PATHS.find { |p| File.exist?(p) }
37
+ raise LoadError, "balanced_vrp_clustering_native not built. Run: bundle exec rake native:compile" unless path
38
+
39
+ require path
@@ -0,0 +1,64 @@
1
+ # Copyright © Mapotempo, 2020
2
+ #
3
+ # This file is part of Mapotempo.
4
+ #
5
+ # Mapotempo is free software. You can redistribute it and/or
6
+ # modify since you respect the terms of the GNU Affero General
7
+ # Public License as published by the Free Software Foundation,
8
+ # either version 3 of the License, or (at your option) any later version.
9
+ #
10
+ # Mapotempo is distributed in the hope that it will be useful, but WITHOUT
11
+ # ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
12
+ # or FITNESS FOR A PARTICULAR PURPOSE. See the Licenses for more details.
13
+ #
14
+ # You should have received a copy of the GNU Affero General Public License
15
+ # along with Mapotempo. If not, see:
16
+ # <http://www.gnu.org/licenses/agpl.html>
17
+ #
18
+ require 'active_support/concern'
19
+
20
+ module OverloadableFunctions
21
+ extend ActiveSupport::Concern
22
+
23
+ def compatible_characteristics?(service_chars, vehicle_chars)
24
+ # Compatile service and vehicle
25
+ # if the vehicle cannot serve the service due to sticky_vehicle_id
26
+ return false if !service_chars[:v_id].empty? && (service_chars[:v_id] & vehicle_chars[:id]).empty?
27
+
28
+ # if the service needs a skill that the vehicle doesn't have
29
+ return false if !(service_chars[:skills] - vehicle_chars[:skills]).empty?
30
+
31
+ # if service and vehicle have no matching days
32
+ return false if (service_chars[:day_skills] & vehicle_chars[:day_skills]).empty?
33
+
34
+ true # if not, they are compatible
35
+ end
36
+
37
+ def compute_limits(cut_symbol, cut_ratio, vehicles, data_items)
38
+ strict_limits = vehicles.collect{ |vehicle|
39
+ s_l = { duration: vehicle[:duration] } # incase duration is not supplied inside the capacities
40
+ vehicle[:capacities]&.each{ |unit, limit|
41
+ s_l[unit] = limit
42
+ }
43
+ vehicle[:duration] = s_l[:duration] # incase duration is only supplied inside the capacities
44
+ vehicle[:capacities] ||= { duration: s_l[:duration] } # incase capacities field were not suplied at all
45
+ s_l
46
+ }
47
+
48
+ if cut_symbol
49
+ total_quantity = Hash.new(0)
50
+ @unit_symbols ||= nil
51
+ (@unit_symbols || (cut_symbol && [cut_symbol]))&.each{ |unit|
52
+ total_quantity[unit] = data_items.sum{ |item| item[3][unit].to_f }
53
+ }
54
+
55
+ total_capacity = vehicles.sum{ |vehicle| vehicle[:capacities][cut_symbol].to_f }
56
+ metric_limits = vehicles.collect{ |vehicle|
57
+ vehicle_share = total_capacity.positive? ? vehicle[:capacities][cut_symbol] / total_capacity : 1.0 / vehicles.size
58
+ { limit: cut_ratio * total_quantity[cut_symbol] * vehicle_share }
59
+ }
60
+ end
61
+
62
+ [strict_limits, metric_limits]
63
+ end
64
+ end
@@ -0,0 +1,283 @@
1
+ # Copyright © Mapotempo, 2020
2
+ #
3
+ # This file is part of Mapotempo.
4
+ #
5
+ # Mapotempo is free software. You can redistribute it and/or
6
+ # modify since you respect the terms of the GNU Affero General
7
+ # Public License as published by the Free Software Foundation,
8
+ # either version 3 of the License, or (at your option) any later version.
9
+ #
10
+ # Mapotempo is distributed in the hope that it will be useful, but WITHOUT
11
+ # ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
12
+ # or FITNESS FOR A PARTICULAR PURPOSE. See the Licenses for more details.
13
+ #
14
+ # You should have received a copy of the GNU Affero General Public License
15
+ # along with Mapotempo. If not, see:
16
+ # <http://www.gnu.org/licenses/agpl.html>
17
+ #
18
+
19
+ module Helper
20
+ R = 6_378_137 # Earth's radius in meters
21
+
22
+ L = 111_321 # Length of a degree (of lon and lat) in meters
23
+
24
+ RADIANS_IN_A_DEGREE = Math::PI / 180.0
25
+
26
+ BALANCE_VIOLATION_COLOR_LIMITS = [
27
+ [0.50, 'red'],
28
+ [0.25, 'purple'],
29
+ [0.10, 'blue'],
30
+ [0.05, 'white'],
31
+ [0.00, 'green']
32
+ ].freeze
33
+
34
+ def self.fixnum_max
35
+ (2**(0.size * 8 - 2) - 1)
36
+ end
37
+
38
+ def self.fixnum_min
39
+ -(2**(0.size * 8 - 2))
40
+ end
41
+
42
+ def self.deg2rad(degree)
43
+ # Converts degrees to radians
44
+ degree * RADIANS_IN_A_DEGREE
45
+ end
46
+
47
+ def self.approximate_polygon_area(coordinates)
48
+ # A rudimentary way to calculate area (m^2) from coordinates.
49
+ # When the shape is vaguely convex, it gives okay results.
50
+ # https://stackoverflow.com/a/32912727/1200528
51
+ return 0.0 unless coordinates.size > 2
52
+
53
+ area = 0.0
54
+ coor_p = coordinates.first
55
+ coordinates[1..-1].each{ |coor|
56
+ area += deg2rad(coor[1] - coor_p[1]) * (2 + Math.sin(deg2rad(coor_p[0])) + Math.sin(deg2rad(coor[0])))
57
+ coor_p = coor
58
+ }
59
+
60
+ (area * R**2 / 2).abs
61
+ end
62
+
63
+ def self.approximate_quadrilateral_polygon(coordinates)
64
+ # Approximates the tetragon of a coordinate list.
65
+ # First two very distant points (c1 and c2) are selected based on a
66
+ # random distant point crand and c0.
67
+ # Then two more points (c3 and c4) are selected on each side of the
68
+ # line created by c1-c2 to maximize the coverage.
69
+
70
+ # c1 and c2 are approximately the most distant two points.
71
+ crand = coordinates[rand(coordinates.size)]
72
+ c0 = coordinates.max_by{ |c_i| Helper.flying_distance(crand, c_i) }
73
+ c1 = coordinates.max_by{ |c_i| Helper.flying_distance(c0, c_i) }
74
+ c2 = coordinates.max_by{ |c_i| Helper.flying_distance(c1, c_i) }
75
+
76
+ # find two points (c3 and c4) on each side of the line passing through c1 and c2
77
+ # which are the farthest away from the line.
78
+ c3 = c4 = nil
79
+ max_distance = { up: 0, down: 0 }
80
+ coordinates.each{ |c_i|
81
+ position, distance_to_line = position_and_distance_to_line(c1, c2, c_i)
82
+
83
+ if position.positive? && distance_to_line > max_distance[:up]
84
+ max_distance[:up] = distance_to_line
85
+ c3 = c_i
86
+ elsif position.negative? && distance_to_line > max_distance[:down]
87
+ max_distance[:down] = distance_to_line
88
+ c4 = c_i
89
+ end
90
+ }
91
+
92
+ # in case it is a triangle compact it
93
+ [c1, c3, c2, c4, c1].compact
94
+ end
95
+
96
+ def self.position_and_distance_to_line(l_begin, l_end, point)
97
+ # position and aproximate distance of a point to the line passing through points (l_begin, l_end)
98
+ lat_diff = l_end[0] - l_begin[0]
99
+ lon_diff = l_end[1] - l_begin[1]
100
+
101
+ [
102
+ position = lat_diff * (point[1] - l_begin[1]) - lon_diff * (point[0] - l_begin[0]),
103
+ position.abs / Math.sqrt(lat_diff**2 + lon_diff**2)
104
+ ]
105
+ end
106
+
107
+ def self.compute_approximate_route_time(area, visit_count, speed, total_work_days)
108
+ # Based on Equation (6) of doi:10.1016/j.cor.2004.07.001
109
+
110
+ k1 = 0.765 # a coefficient that depends on the distance metric and routing strategy
111
+ k2 = 1.45 # is a corrective coefficient (route factor) reflecting the road network impedance
112
+ k0 = k1 * k2
113
+
114
+ total_work_days * k0 * Math.sqrt(area * visit_count / total_work_days**1.5) / speed.to_f
115
+ end
116
+
117
+ def self.flying_distance(loc_a, loc_b)
118
+ return 0.0 unless loc_a[0] && loc_b[0]
119
+
120
+ if (loc_a[0] - loc_b[0]).abs < 30 && [loc_a[0].abs, loc_b[0].abs].max + (loc_a[1] - loc_b[1]).abs < 100
121
+ # These limits ensures that relative error cannot be much greather than 2%
122
+ # For a distance like Bordeaux - Berlin, relative error between
123
+ # euclidean_distance and flying_distance is 0.1%.
124
+ # That is no need for trigonometric calculation.
125
+ return euclidean_distance(loc_a, loc_b)
126
+ end
127
+
128
+ deg2rad_loc_a_lat = deg2rad(loc_a[0])
129
+ deg2rad_loc_b_lat = deg2rad(loc_b[0])
130
+
131
+ intermediate = Math.sin((deg2rad_loc_b_lat - deg2rad_loc_a_lat) / 2)**2 +
132
+ Math.sin((deg2rad(loc_b[1]) - deg2rad(loc_a[1])) / 2)**2 * Math.cos(deg2rad_loc_b_lat) * Math.cos(deg2rad_loc_a_lat)
133
+
134
+ R * 2 * Math.atan2(Math.sqrt(intermediate), Math.sqrt(1 - intermediate))
135
+ end
136
+
137
+ def self.euclidean_distance(loc_a, loc_b)
138
+ return 0.0 unless loc_a[0] && loc_b[0]
139
+
140
+ delta_lat = loc_a[0] - loc_b[0]
141
+ delta_lon = (loc_a[1] - loc_b[1]) * Math.cos(deg2rad((loc_a[0] + loc_b[0]) / 2.0)) # Correct the length of a lon difference with cosine of avereage latitude
142
+
143
+ L * Math.sqrt(delta_lat**2 + delta_lon**2)
144
+ end
145
+
146
+ def self.unsquared_matrix(matrix, a_indices, b_indices)
147
+ a_indices.map{ |a|
148
+ b_indices.map { |b|
149
+ matrix[b][a]
150
+ }
151
+ }
152
+ end
153
+
154
+ def self.check_if_projection_inside_the_line_segment(point_coord, line_beg_coord, line_end_coord, margin)
155
+ # margin: if (0 > margin > 1), the percentage of the line segment that will be considered as "outside"
156
+ # margin: if (margin < 0), the percentage that the "inside" zone is extended
157
+ # [0, 1]: coordinates [lat, lon] or [lon, lat]
158
+ line_direction = [0, 1].collect{ |i| line_end_coord[i] - line_beg_coord[i] }
159
+ point_direction = [0, 1].collect{ |i| point_coord[i] - line_beg_coord[i] }
160
+
161
+ projection_scaler = [0, 1].sum{ |i| line_direction[i] * point_direction[i] } / [0, 1].sum{ |i| line_direction[i]**2 }
162
+ # If projection_scaler
163
+ # >1: the projection is after the line segment end
164
+ # <0: it is before the line segment begin
165
+ # otherwise: it is on the line segment define by begin_coord end end_coord
166
+
167
+ # If the following holds, it is inside the margin of the line segment
168
+ projection_scaler >= 0 + 0.5 * margin && projection_scaler <= 1 - 0.5 * margin
169
+ end
170
+
171
+ def self.colorize_balance_violations(balance_violations)
172
+ balance_violations.collect{ |i|
173
+ i = i.round(2)
174
+
175
+ color_index = 0
176
+ BALANCE_VIOLATION_COLOR_LIMITS.each{ |cl|
177
+ break if i.abs >= cl[0]
178
+
179
+ color_index += 1
180
+ }
181
+
182
+ i.to_s.send("#{BALANCE_VIOLATION_COLOR_LIMITS[color_index][1]}#{i.negative? ? 'ish' : ''}")
183
+ }
184
+ end
185
+
186
+ def self.output_cluster_stats(centroids, logger = nil)
187
+ return unless logger
188
+
189
+ header = %w[
190
+ zone
191
+ nb_vehicles
192
+ capacities
193
+ skills
194
+ nb_visits
195
+ total_duration(excel_format)
196
+ total_visit_duration
197
+ total_intra_zone_route_duration
198
+ total_depot_route_duration
199
+ ]
200
+ rows = centroids.map.with_index do |c, i|
201
+ props = c[4] || {}
202
+ load = c[3] || {}
203
+ visit_duration = load[:duration].to_f
204
+ route_time = props[:route_time].to_f
205
+ depot_duration = props[:duration_from_and_to_depot].to_f
206
+ work_days = (props[:total_work_days] || 1).to_f
207
+
208
+ [
209
+ i + 1,
210
+ props[:vehicle_count],
211
+ props[:capacities],
212
+ props[:skills],
213
+ props[:visit_count],
214
+ (visit_duration + route_time + depot_duration * work_days) / 86400,
215
+ visit_duration / 86400,
216
+ route_time / 86400,
217
+ (depot_duration * work_days) / 86400
218
+ ].join(',')
219
+ end
220
+
221
+ logger.debug "cluster_stats:\n#{header.join(',')}\n#{rows.join("\n")}"
222
+ end
223
+ end
224
+
225
+ # Some functions for convenience
226
+ # In the same vein as active_support Enumerable.sum implementation
227
+ module Enumerable
228
+ # Provide the average on an array
229
+ # [5, 15, 7].mean # => 9.0
230
+ def mean(type = :arithmetic)
231
+ return nil if empty?
232
+
233
+ case type
234
+ when :arithmetic
235
+ inject(0) { |sum, x| sum + x } / size.to_f
236
+ when :geometric
237
+ # can overflow for large inputs
238
+ reduce(:*)**(1.0 / size) if all?(&:positive?)
239
+ else
240
+ raise RuntimeError, "Unknown mean type: #{type}"
241
+ end
242
+ end
243
+
244
+ # If the array has an odd number, then simply pick the one in the middle
245
+ # If the array size is even, then we return the mean of the two middle.
246
+ # [5, 15, 7].median # => 7
247
+ def median(already_sorted = false)
248
+ return nil if empty?
249
+
250
+ ret = already_sorted ? self : sort
251
+ m_pos = size / 2 # no to_f!
252
+ size.odd? ? ret[m_pos] : ret[m_pos - 1..m_pos].mean
253
+ end
254
+
255
+ # The mode is the single most popular item in the array.
256
+ # [5, 15, 10, 15].mode # => 15
257
+ def mode
258
+ modes(false)[0]
259
+ end
260
+
261
+ # In case there are multiple elements with the highest occurence
262
+ # [5, 15, 10, 10, 15].modes # => [10, 15]
263
+ # [5, 15, 10, 15].modes # => [15] (Note that modes() returns an array)
264
+ def modes(find_all = true)
265
+ return nil if empty?
266
+
267
+ histogram = each_with_object(Hash.new(0)) { |n, h| h[n] += 1 }
268
+ modes = nil
269
+ histogram.each_pair do |item, times|
270
+ modes << item if find_all && !modes.nil? && times == modes[0]
271
+ modes = [times, item] if (modes && times > modes[0]) || (modes.nil? && times > 1)
272
+ end
273
+ modes.nil? ? nil : modes[1...modes.size]
274
+ end
275
+
276
+ # group_by like counting routine for convenience
277
+ def count_by(&block)
278
+ self.group_by(&block)
279
+ .map{ |key, items| [key, items&.count] }
280
+ .to_h
281
+ end
282
+
283
+ end
@@ -0,0 +1,188 @@
1
+ # Copyright © Mapotempo, 2019
2
+ #
3
+ # This file is part of Mapotempo.
4
+ #
5
+ # Mapotempo is free software. You can redistribute it and/or
6
+ # modify since you respect the terms of the GNU Affero General
7
+ # Public License as published by the Free Software Foundation,
8
+ # either version 3 of the License, or (at your option) any later version.
9
+ #
10
+ # Mapotempo is distributed in the hope that it will be useful, but WITHOUT
11
+ # ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
12
+ # or FITNESS FOR A PARTICULAR PURPOSE. See the Licenses for more details.
13
+ #
14
+ # You should have received a copy of the GNU Affero General Public License
15
+ # along with Mapotempo. If not, see:
16
+ # <http://www.gnu.org/licenses/agpl.html>
17
+ #
18
+
19
+ # Implementation of
20
+ # CONCAVE HULL: A K-NEAREST NEIGHBOURS APPROACH FOR THE COMPUTATION OF THE REGION OCCUPIED BY A SET OF POINTS
21
+ # By Adriano Moreira and Maribel Yasmina Santos
22
+
23
+ module Hull
24
+ def self.get_hull(vector)
25
+ concave_hull(vector, 5) || convex_hull(vector)
26
+ end
27
+
28
+ # vector of unique points
29
+ def self.concave_hull(vector, k = 3)
30
+ if vector.size <= 3
31
+ vector
32
+ else
33
+ k = [k, vector.size - 1].min
34
+ self.concave_hull_(vector, k)
35
+ end
36
+ end
37
+
38
+ def self.concave_hull_(vector, k = 3)
39
+ if k == vector.size
40
+ log 'ConcaveHull build fail'
41
+ return nil
42
+ end
43
+ kk = [[k, 3].max, vector.size - 1].min # make sure k>=3 and k neighbours can be found
44
+ first_point = vector.min_by{ |i| i[1] } # find min y point
45
+ hull = [first_point] # initialize the hull with the first point
46
+ current_point = first_point
47
+ dataset = vector - [first_point]
48
+ previous_angle = 0
49
+ while (current_point != first_point || hull.size == 1) && !dataset.empty?
50
+ if hull.size == 3
51
+ dataset += [first_point] # add the firstPoint again
52
+ end
53
+ k_nearest_points = nearestpoints(dataset, current_point, kk) # find the nearest neighbours
54
+ # sort the candidates (neighbours) in descending order of right-hand turn
55
+ c_points = k_nearest_points.sort_by{ |point| -angle(point, current_point, previous_angle) }
56
+ its = true
57
+ i = -1
58
+ while its && i < c_points.size - 1 # select the first candidate that does not intersects any of the polygon edges
59
+ i += 1
60
+ last_point = (c_points[i] == first_point) ? 1 : 0
61
+ j = 1
62
+ its = false
63
+ while !its && j < hull.size - last_point
64
+ its = intersect?(hull[-1], c_points[i], hull[-j - 1], hull[-j])
65
+ j += 1
66
+ end
67
+ end
68
+ # since all candidates intersect at least one edge, try again with a higher number of neighbours
69
+ if its
70
+ return concave_hull_(vector, kk + 1)
71
+ end
72
+
73
+ previous_angle = angle(c_points[i], current_point)
74
+ current_point = c_points[i]
75
+ hull << current_point # a valid candidate was found
76
+ dataset -= [current_point]
77
+ end
78
+
79
+ # check if all the given points are inside the computed polygon
80
+ dataset.size.times{ |ii|
81
+ inside = point_in_polygon(dataset[ii], hull)
82
+ if !inside
83
+ # since at least one point is out of the computed polygon, try again with a higher number of neighbours
84
+ return concave_hull_(vector, kk + 1)
85
+ end
86
+ }
87
+
88
+ hull
89
+ end
90
+
91
+ def self.convex_hull(vector)
92
+ log 'Perform Convex hull'
93
+ vector.sort!{ |a, b| a.last <=> b.last || a.first <=> b.first }.uniq!
94
+ return vector if vector.size <= 3
95
+
96
+ lower = []
97
+ vector.each{ |point|
98
+ lower.pop while lower.size > 1 && cross(lower[-2], lower[-1], point) <= 0
99
+ lower << point
100
+ }
101
+ upper = []
102
+ vector.reverse_each{ |point|
103
+ upper.pop while upper.size > 1 && cross(upper[-2], upper[-1], point) <= 0
104
+ upper << point
105
+ }
106
+ lower + upper
107
+ end
108
+
109
+ def self.nearestpoints(dataset, current_point, kk)
110
+ dataset.sort_by { |d|
111
+ (d[0] - current_point[0])**2 + (d[1] - current_point[1])**2
112
+ }[0..kk]
113
+ end
114
+
115
+ def self.angle(point, current_point, previous_angle = 0)
116
+ (Math.atan2(point[1] - current_point[1], point[0] - current_point[0]) - previous_angle) % (Math::PI * 2) - Math::PI
117
+ end
118
+
119
+ def self.intersect?(s1p1, s1p2, s2p1, s2p2)
120
+ p0_x, p0_y = s1p1
121
+ p1_x, p1_y = s1p2
122
+ p2_x, p2_y = s2p1
123
+ p3_x, p3_y = s2p2
124
+
125
+ s10_x = p1_x - p0_x
126
+ s10_y = p1_y - p0_y
127
+ s32_x = p3_x - p2_x
128
+ s32_y = p3_y - p2_y
129
+
130
+ denom = s10_x * s32_y - s32_x * s10_y
131
+ if denom.zero?
132
+ return false
133
+ end
134
+
135
+ denom_positive = denom > 0
136
+
137
+ s02_x = p0_x - p2_x
138
+ s02_y = p0_y - p2_y
139
+ s_numer = s10_x * s02_y - s10_y * s02_x
140
+ if (s_numer < 0) == denom_positive
141
+ return false
142
+ end
143
+
144
+ t_numer = s32_x * s02_y - s32_y * s02_x
145
+ if (t_numer < 0) == denom_positive
146
+ return false
147
+ end
148
+
149
+ if ((s_numer > denom) == denom_positive) || ((t_numer > denom) == denom_positive)
150
+ return false
151
+ end
152
+
153
+ t = t_numer / denom
154
+ x = p0_x + (t * s10_x)
155
+ y = p0_y + (t * s10_y)
156
+
157
+ if [s1p1, s1p2, s2p1, s2p2].include?([x, y])
158
+ return false
159
+ end
160
+
161
+ true
162
+ end
163
+
164
+ def self.point_in_polygon(point, polygon)
165
+ inside = false
166
+ size = polygon.size
167
+ size.times.each{ |i|
168
+ min, max = [polygon[i][0], polygon[(i + 1) % size][0]].minmax
169
+ next if min >= point[0] || point[0] > max
170
+
171
+ # vertical ray, could return non homogenous result with a point on a vertical edge
172
+ p = (polygon[i][1] - polygon[(i + 1) % size][1])
173
+ q = (polygon[i][0] - polygon[(i + 1) % size][0])
174
+ point_y = (point[0] - polygon[i][0]) * p / q + polygon[i][1]
175
+ if point_y > point[1]
176
+ inside = !inside
177
+ end
178
+ }
179
+ inside
180
+ end
181
+
182
+ def self.cross(o, a, b)
183
+ oy, ox = o
184
+ ay, ax = a
185
+ by, bx = b
186
+ (ax - ox) * (by - oy) - (ay - oy) * (bx - ox)
187
+ end
188
+ end