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.
- checksums.yaml +7 -0
- data/lib/balanced_vrp_clustering/ai4r_compat.rb +152 -0
- data/lib/balanced_vrp_clustering/rust_engine.rb +234 -0
- data/lib/balanced_vrp_clustering.rb +1188 -0
- data/lib/balanced_vrp_clustering_native.rb +39 -0
- data/lib/concerns/overloadable_functions.rb +64 -0
- data/lib/helpers/helper.rb +283 -0
- data/lib/helpers/hull.rb +188 -0
- data/rust/Cargo.lock +585 -0
- data/rust/Cargo.toml +3 -0
- data/rust/balanced_vrp_clustering_core/Cargo.toml +25 -0
- data/rust/balanced_vrp_clustering_core/src/clusterer.rs +1280 -0
- data/rust/balanced_vrp_clustering_core/src/compatibility.rs +78 -0
- data/rust/balanced_vrp_clustering_core/src/distance.rs +179 -0
- data/rust/balanced_vrp_clustering_core/src/lib.rs +29 -0
- data/rust/balanced_vrp_clustering_core/src/limits.rs +62 -0
- data/rust/balanced_vrp_clustering_core/src/local_speed.rs +99 -0
- data/rust/balanced_vrp_clustering_core/src/main.rs +46 -0
- data/rust/balanced_vrp_clustering_core/src/relations.rs +143 -0
- data/rust/balanced_vrp_clustering_core/src/rng.rs +50 -0
- data/rust/balanced_vrp_clustering_core/src/types.rs +234 -0
- data/rust/balanced_vrp_clustering_core/tests/integration.rs +71 -0
- data/rust/balanced_vrp_clustering_native/Cargo.toml +33 -0
- data/rust/balanced_vrp_clustering_native/extconf.rb +26 -0
- data/rust/balanced_vrp_clustering_native/src/lib.rs +57 -0
- metadata +102 -0
|
@@ -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
|
data/lib/helpers/hull.rb
ADDED
|
@@ -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
|