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,1188 @@
|
|
|
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
|
+
require 'rubygems'
|
|
20
|
+
require 'active_support'
|
|
21
|
+
require 'active_support/core_ext'
|
|
22
|
+
|
|
23
|
+
require_relative 'balanced_vrp_clustering/ai4r_compat'
|
|
24
|
+
|
|
25
|
+
require 'helpers/helper.rb'
|
|
26
|
+
require 'concerns/overloadable_functions.rb'
|
|
27
|
+
require_relative 'balanced_vrp_clustering/rust_engine'
|
|
28
|
+
|
|
29
|
+
require 'helpers/hull.rb'
|
|
30
|
+
|
|
31
|
+
INCOMPATIBILITY_DISTANCE_PENALTY = 2**32
|
|
32
|
+
LOCAL_SPEED_CELL_DEG = 0.01
|
|
33
|
+
LOCAL_SPEED_NEIGHBOR_LIMIT = 100
|
|
34
|
+
# Deterministic palette for optional geojson dump (no color-generator dependency).
|
|
35
|
+
GEOJSON_PALETTE = %w[
|
|
36
|
+
#e6194b #3cb44b #ffe119 #4363d8 #f58231 #911eb4 #46f0f0 #f032e6 #bcf60c #fabebe
|
|
37
|
+
#008080 #e6beff #9a6324 #fffac8 #800000 #aaffc3 #808000 #ffd8b1 #000075 #808080
|
|
38
|
+
].freeze
|
|
39
|
+
|
|
40
|
+
module Ai4r
|
|
41
|
+
module Clusterers
|
|
42
|
+
class BalancedVRPClustering < Clusterer
|
|
43
|
+
LINKING_RELATIONS = %i[order same_route sequence same_vehicle shipment].freeze
|
|
44
|
+
BINDING_RELATIONS = %i[order same_route sequence same_vehicle].freeze
|
|
45
|
+
|
|
46
|
+
include OverloadableFunctions
|
|
47
|
+
|
|
48
|
+
attr_reader :iteration
|
|
49
|
+
attr_reader :cut_limit
|
|
50
|
+
|
|
51
|
+
parameters_info max_iterations: 'Maximum number of iterations to build the clusterer.',
|
|
52
|
+
distance_function: 'Custom distance between two data items.',
|
|
53
|
+
centroid_indices: 'Indices of data items to use as initial centroids.',
|
|
54
|
+
vehicles: 'Attributes of each cluster to generate. If centroid_indices are provided
|
|
55
|
+
then vehicles should be ordered according to centroid_indices order',
|
|
56
|
+
vehicles_infos: '(DEPRECATED) Use vehicles',
|
|
57
|
+
logger: 'The logger to write the output. All written to logger.debug at the moment.',
|
|
58
|
+
geojson_dump_folder: 'If set, every geojson_dump_freq many iterations,'\
|
|
59
|
+
'the geojson will be dumped to geojson_dump_folder.',
|
|
60
|
+
geojson_dump_freq: 'Sets the frequency for geojson dump. (Default: 2)',
|
|
61
|
+
distance_matrix: 'Distance matrix to use to compute distance between two data_items',
|
|
62
|
+
compatibility_function: 'Custom implementation of a compatibility_function.'\
|
|
63
|
+
'It must be a closure receiving a data item and a centroid and return a '\
|
|
64
|
+
'boolean (true: if compatible and false: if incompatible).',
|
|
65
|
+
on_empty: '(DEPRECATED) the only possible option is \'closest\''\
|
|
66
|
+
' -- i.e., relocate the empty cluster to the closest compatible point.'
|
|
67
|
+
|
|
68
|
+
def initialize
|
|
69
|
+
@max_iterations = nil
|
|
70
|
+
@distance_function = nil
|
|
71
|
+
@centroid_indices = []
|
|
72
|
+
@logger = nil
|
|
73
|
+
@distance_matrix = nil
|
|
74
|
+
@unit_symbols = nil
|
|
75
|
+
@geojson_colors = nil
|
|
76
|
+
@on_empty = 'closest' # the other options are not available
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def build(data_set, cut_symbol, related_item_indices = {}, cut_ratio = 1.0, options = {})
|
|
80
|
+
# Build a new clusterer, using data items found in data_set.
|
|
81
|
+
# Items will be clustered in "number_of_clusters" different
|
|
82
|
+
# clusters. Each item is defined by :
|
|
83
|
+
# index 0 : latitude
|
|
84
|
+
# index 1 : longitude
|
|
85
|
+
# index 2 : item_id
|
|
86
|
+
# index 3 : unit_quantities -> for each unit, quantity associated to this item
|
|
87
|
+
# index 4 : characteristics -> { v_id: sticky_vehicle_ids, skills: skills, day_skills: day_skills, matrix_index: matrix_index }
|
|
88
|
+
|
|
89
|
+
# First of all, set and display the seed
|
|
90
|
+
options[:seed] ||= Random.new_seed
|
|
91
|
+
@logger&.info "Clustering with seed=#{options[:seed]}"
|
|
92
|
+
srand options[:seed]
|
|
93
|
+
|
|
94
|
+
# DEPRECATED variables (to be removed before public release)
|
|
95
|
+
@vehicles ||= @vehicles_infos # (DEPRECATED)
|
|
96
|
+
|
|
97
|
+
raise ArgumentError, 'Each data_item should be uniq' if data_set.data_items.size != data_set.data_items.uniq.size
|
|
98
|
+
|
|
99
|
+
### return clean errors if inconsistent data ###
|
|
100
|
+
if distance_matrix && data_set.data_items.any?{ |item| !item[4][:matrix_index] }
|
|
101
|
+
raise ArgumentError, 'Distance matrix provided: matrix index should be provided for all vehicles and items'
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
data_set.data_items.each do |item|
|
|
105
|
+
if item[4][:duration_from_and_to_depot]&.size.to_i > @vehicles.size
|
|
106
|
+
raise ArgumentError, "duration_from_and_to_depot size (#{item[4][:duration_from_and_to_depot].size}) is greater than vehicles size (#{@vehicles.size}) for item #{item[2]}"
|
|
107
|
+
end
|
|
108
|
+
if !item[4].key?(:duration_from_and_to_depot) || item[4][:duration_from_and_to_depot].nil? || item[4][:duration_from_and_to_depot].size != @vehicles.size
|
|
109
|
+
msg = "[WARNING] duration_from_and_to_depot was missing or incomplete for item #{item[2]}. It has been initialized or completed with 0s."
|
|
110
|
+
if defined?(@logger) && @logger
|
|
111
|
+
@logger.warn msg
|
|
112
|
+
else
|
|
113
|
+
warn msg
|
|
114
|
+
end
|
|
115
|
+
item[4][:duration_from_and_to_depot] ||= []
|
|
116
|
+
item[4][:duration_from_and_to_depot].fill(0, item[4][:duration_from_and_to_depot].size...@vehicles.size)
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
unless @on_empty == 'closest'
|
|
121
|
+
raise ArgumentError, 'Only \'closest\' option is supported for :on_empty parameter'
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
if cut_symbol && !@vehicles.all?{ |v_i| v_i[:capacities]&.has_key?(cut_symbol) || (cut_symbol == :duration && v_i[:duration]) }
|
|
125
|
+
# TODO: remove this condition and handle the infinity capacities properly.
|
|
126
|
+
raise ArgumentError, 'All vehicles should have a limit for the unit corresponding to the cut symbol'
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
@related_item_indices_for_rust = related_item_indices.transform_values { |groups| groups.map(&:dup) }
|
|
130
|
+
connect_linked_items(data_set.data_items, related_item_indices)
|
|
131
|
+
|
|
132
|
+
### values ###
|
|
133
|
+
@data_set = data_set
|
|
134
|
+
@cut_symbol = cut_symbol
|
|
135
|
+
@unit_symbols = [cut_symbol].compact
|
|
136
|
+
@unit_symbols |= @vehicles.collect{ |c| c[:capacities].keys }.flatten.uniq if @vehicles.any?{ |c| c[:capacities] }
|
|
137
|
+
@number_of_clusters = @vehicles.size
|
|
138
|
+
|
|
139
|
+
### default values ###
|
|
140
|
+
@logger ||= nil
|
|
141
|
+
@geojson_dump_folder ||= nil
|
|
142
|
+
@geojson_dump_freq ||= 2
|
|
143
|
+
@max_balance_violation = 0
|
|
144
|
+
@max_iterations ||= [0.5 * @data_set.data_items.size, 100].max
|
|
145
|
+
|
|
146
|
+
@distance_function ||= lambda do |a, b|
|
|
147
|
+
if @distance_matrix
|
|
148
|
+
a_idx = a[4][:matrix_index]
|
|
149
|
+
b_idx = b[4][:matrix_index]
|
|
150
|
+
d = @distance_matrix[a_idx][b_idx]
|
|
151
|
+
b_dist = @distance_matrix[b_idx][a_idx]
|
|
152
|
+
d <= b_dist ? d : b_dist
|
|
153
|
+
else
|
|
154
|
+
Helper.flying_distance(a, b)
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
@compatibility_function ||= lambda do |data_item, centroid|
|
|
159
|
+
if compatible_characteristics?(data_item[4], centroid[4])
|
|
160
|
+
true
|
|
161
|
+
else
|
|
162
|
+
false
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
@data_set.data_items.each{ |item|
|
|
167
|
+
item[3].default = nil
|
|
168
|
+
item[4].default = nil
|
|
169
|
+
item[4][:v_id] ||= []
|
|
170
|
+
item[4][:skills] ||= []
|
|
171
|
+
item[4][:day_skills] ||= %w[0_day_skill 1_day_skill 2_day_skill 3_day_skill 4_day_skill 5_day_skill 6_day_skill]
|
|
172
|
+
item[3][:visits] ||= 1
|
|
173
|
+
item[4][:centroid_weights] = {
|
|
174
|
+
limit: Array.new(@number_of_clusters, 1),
|
|
175
|
+
compatibility: 1
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
@vehicles.each{ |vehicle_info|
|
|
180
|
+
vehicle_info[:id] = [vehicle_info[:id]].flatten if vehicle_info[:id]
|
|
181
|
+
vehicle_info[:total_work_days] ||= 1
|
|
182
|
+
vehicle_info[:skills] ||= []
|
|
183
|
+
vehicle_info[:day_skills] ||= %w[0_day_skill 1_day_skill 2_day_skill 3_day_skill 4_day_skill 5_day_skill 6_day_skill]
|
|
184
|
+
vehicle_info[:vehicle_count] ||= 1
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
# Initialise the [:centroid_weights][:compatibility] of data_items which need specifique vehicles
|
|
188
|
+
# These weights are increased if the data_item is not assigned to its closest clusters due to incompatibility
|
|
189
|
+
compatibility_groups = Hash.new{ [] }
|
|
190
|
+
@data_set.data_items.group_by{ |d_i| [d_i[4][:v_id], d_i[4][:skills], d_i[4][:day_skills]] }.each{ |_skills, group|
|
|
191
|
+
compatibility_groups[@vehicles.collect{ |vehicle_info| @compatibility_function.call(group[0], [nil, nil, nil, nil, vehicle_info]) ? 1 : 0 }] += group
|
|
192
|
+
}
|
|
193
|
+
@expected_n_visits = @data_set.data_items.sum{ |d_i| d_i[3][:visits] } / @number_of_clusters.to_f
|
|
194
|
+
compatibility_groups.each{ |compatibility, group|
|
|
195
|
+
compatible_vehicle_count = [compatibility.sum, 1].max
|
|
196
|
+
incompatible_vehicle_count = @vehicles.size - compatible_vehicle_count
|
|
197
|
+
|
|
198
|
+
compatibility_weight = (@expected_n_visits**((1.0 + Math.log(incompatible_vehicle_count / @vehicles.size.to_f + 0.1)) / (1.0 + Math.log(1.1)))) / [group.size / compatible_vehicle_count, 1.0].max
|
|
199
|
+
|
|
200
|
+
group.each{ |d_i| d_i[4][:centroid_weights][:compatibility] = compatibility_weight.ceil }
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
if use_rust_engine?(options)
|
|
204
|
+
validate_centroid_indices_for_linked_items!(@data_set.data_items)
|
|
205
|
+
@logger&.info 'Clustering engine: rust'
|
|
206
|
+
BalancedVRPClusteringRustEngine.build(self, @data_set, cut_symbol, related_item_indices, cut_ratio, options)
|
|
207
|
+
@strict_limitations, @cut_limit = compute_limits(cut_symbol, cut_ratio, @vehicles, @data_set.data_items)
|
|
208
|
+
@balance_coeff ||= Array.new(@number_of_clusters, 1.0)
|
|
209
|
+
Helper.output_cluster_stats(@centroids, @logger)
|
|
210
|
+
output_cluster_geojson
|
|
211
|
+
return self
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
@strict_limitations, @cut_limit = compute_limits(cut_symbol, cut_ratio, @vehicles, @data_set.data_items)
|
|
215
|
+
|
|
216
|
+
### algo start ###
|
|
217
|
+
@iteration = 0
|
|
218
|
+
|
|
219
|
+
@items_with_limit_violation = []
|
|
220
|
+
@clusters_with_limit_violation = Array.new(@number_of_clusters){ [] }
|
|
221
|
+
|
|
222
|
+
@balance_coeff = Array.new(@number_of_clusters, 1.0)
|
|
223
|
+
@evaluate_distance_buffer = Array.new(@number_of_clusters)
|
|
224
|
+
@cluster_load = Array.new(@number_of_clusters) { Hash.new(0.0) }
|
|
225
|
+
|
|
226
|
+
calc_initial_centroids
|
|
227
|
+
|
|
228
|
+
if @cut_symbol
|
|
229
|
+
@total_cut_load = @data_set.data_items.inject(0) { |sum, d| sum + d[3][@cut_symbol].to_f }
|
|
230
|
+
if @total_cut_load.zero?
|
|
231
|
+
@cut_symbol = nil # Disable balancing because there is no point
|
|
232
|
+
else
|
|
233
|
+
# first @centroids.length data_items correspond to centroids, they should remain at the begining of the set
|
|
234
|
+
data_length = @data_set.data_items.size
|
|
235
|
+
@data_set.data_items[[@centroids.length, data_length].min..-1] = @data_set.data_items[[@centroids.length, data_length].min..-1].sort_by { |x| -x[3][@cut_symbol].to_f }
|
|
236
|
+
range_end = (data_length * 0.9).to_i
|
|
237
|
+
range_begin = [(@centroids.length + data_length * 0.1).to_i, range_end].min
|
|
238
|
+
@data_set.data_items[range_begin..range_end] = @data_set.data_items[range_begin..range_end].shuffle
|
|
239
|
+
|
|
240
|
+
@density_cap = Float::EPSILON # used for speed approximation
|
|
241
|
+
@density_range_ratio = 6
|
|
242
|
+
@density_speed_conversion_power = 6
|
|
243
|
+
|
|
244
|
+
calculate_local_speeds # if we want to use route_time for capacity_checks this needs to taken out of the if block
|
|
245
|
+
|
|
246
|
+
area_per_cluster = Helper.approximate_polygon_area(Helper.approximate_quadrilateral_polygon(@data_set.data_items)) / @vehicles.size.to_f
|
|
247
|
+
speed = @data_set.data_items.collect{ |i| i[4][:local_speed] }.mean
|
|
248
|
+
visit_count_per_cluster = @data_set.data_items.sum{ |i| i[3][:visits] } / @vehicles.size.to_f
|
|
249
|
+
total_work_days_per_cluster = @vehicles.sum{ |v| v[:total_work_days] } / @vehicles.size.to_f
|
|
250
|
+
|
|
251
|
+
@approximate_total_route_time = Helper.compute_approximate_route_time(area_per_cluster, visit_count_per_cluster, speed, total_work_days_per_cluster) * @vehicles.size
|
|
252
|
+
end
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
mark_the_items_which_needs_to_stay_at_the_top
|
|
256
|
+
|
|
257
|
+
until stop_criteria_met || @iteration >= @max_iterations
|
|
258
|
+
calculate_membership_clusters
|
|
259
|
+
|
|
260
|
+
output_cluster_geojson if @iteration.modulo(@geojson_dump_freq).zero?
|
|
261
|
+
|
|
262
|
+
update_balance_coefficients
|
|
263
|
+
|
|
264
|
+
recompute_centroids
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
Helper.output_cluster_stats(@centroids, @logger)
|
|
268
|
+
|
|
269
|
+
output_cluster_geojson
|
|
270
|
+
|
|
271
|
+
self
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
def validate_centroid_indices_for_linked_items!(data_items)
|
|
275
|
+
return if @centroid_indices.empty?
|
|
276
|
+
|
|
277
|
+
raise ArgumentError, 'Same centroid_index provided several times' if @centroid_indices.size != @centroid_indices.uniq.size
|
|
278
|
+
raise ArgumentError, 'Wrong number of initial centroids provided' if @centroid_indices.size != @number_of_clusters
|
|
279
|
+
|
|
280
|
+
insert_at_beginning = []
|
|
281
|
+
@centroid_indices.each_with_index do |index, ind|
|
|
282
|
+
raise ArgumentError, 'Invalid centroid index' unless index.is_a?(Integer) && index >= 0 && index < data_items.length
|
|
283
|
+
|
|
284
|
+
item = data_items[index]
|
|
285
|
+
do_forall_linked_items_of(item) do |linked_item|
|
|
286
|
+
next unless insert_at_beginning.include?(linked_item)
|
|
287
|
+
|
|
288
|
+
used_at = insert_at_beginning.index(linked_item)
|
|
289
|
+
msg = "Centroid #{ind} is initialised with a service which has a linked service that is used to initialise centroid #{used_at}"
|
|
290
|
+
raise ArgumentError, msg
|
|
291
|
+
end
|
|
292
|
+
insert_at_beginning << item
|
|
293
|
+
end
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
def use_rust_engine?(options)
|
|
297
|
+
return false if options[:engine] == :ruby
|
|
298
|
+
return false if options[:compatibility_function]
|
|
299
|
+
return false if options[:distance_function]
|
|
300
|
+
return false unless BalancedVRPClusteringRustEngine.available?
|
|
301
|
+
|
|
302
|
+
options[:engine] == :rust || options[:engine].nil?
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
def connect_linked_items(data_items, related_item_indices)
|
|
306
|
+
(LINKING_RELATIONS | BINDING_RELATIONS).each{ |relation|
|
|
307
|
+
related_item_indices[relation]&.each{ |linked_indices|
|
|
308
|
+
raise ArgumentError, 'Each relation group of related_item_indices should contain only unique indices' unless linked_indices.uniq.size == linked_indices.size
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
(LINKING_RELATIONS - BINDING_RELATIONS).each{ |relation|
|
|
313
|
+
related_item_indices[relation]&.each{ |linked_indices|
|
|
314
|
+
raise ArgumentError, 'A service should not appear in multiple non-binding linking relations' if linked_indices.any?{ |ind| data_items[ind][4].key?(:linked_item) }
|
|
315
|
+
|
|
316
|
+
linked_indices << linked_indices.first # create a loop
|
|
317
|
+
(linked_indices.size - 1).times{ |i|
|
|
318
|
+
item = data_items[linked_indices[i]]
|
|
319
|
+
next_item = data_items[linked_indices[i + 1]]
|
|
320
|
+
item[4][:linked_item] = next_item
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
BINDING_RELATIONS.each{ |relation|
|
|
326
|
+
related_item_indices[relation]&.each{ |linked_indices|
|
|
327
|
+
linked_indices << linked_indices.first # create a loop
|
|
328
|
+
(linked_indices.size - 1).times{ |i|
|
|
329
|
+
item = data_items[linked_indices[i]]
|
|
330
|
+
next_item = data_items[linked_indices[i + 1]]
|
|
331
|
+
if !item[4].key?(:linked_item) && !next_item[4].key?(:linked_item)
|
|
332
|
+
item[4][:linked_item] = next_item
|
|
333
|
+
elsif item[4].key?(:linked_item) && next_item[4].key?(:linked_item)
|
|
334
|
+
# either there are two loops to join together or these items are already connected via loop
|
|
335
|
+
first_loop_end = item
|
|
336
|
+
item = item[4][:linked_item] while [first_loop_end, next_item].exclude? item[4][:linked_item]
|
|
337
|
+
next if item[4][:linked_item] == next_item # connected via loop, nothing to do
|
|
338
|
+
|
|
339
|
+
second_loop_end = next_item
|
|
340
|
+
next_item = next_item[4][:linked_item] while next_item[4][:linked_item] != second_loop_end
|
|
341
|
+
|
|
342
|
+
item[4][:linked_item] = second_loop_end
|
|
343
|
+
next_item[4][:linked_item] = first_loop_end
|
|
344
|
+
else
|
|
345
|
+
next_item, item = item, next_item if item[4].key?(:linked_item)
|
|
346
|
+
item[4][:linked_item] = next_item
|
|
347
|
+
loop_end = next_item
|
|
348
|
+
next_item = next_item[4][:linked_item] while next_item[4][:linked_item] != loop_end
|
|
349
|
+
next_item[4][:linked_item] = item unless next_item == item
|
|
350
|
+
end
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
end
|
|
355
|
+
|
|
356
|
+
def move_limit_violating_dataitems
|
|
357
|
+
@limit_violation_count = @items_with_limit_violation.size
|
|
358
|
+
mean_distance_diff = @items_with_limit_violation.collect{ |d| d[1] }.mean
|
|
359
|
+
mean_ratio = @items_with_limit_violation.collect{ |d| d[2] }.mean
|
|
360
|
+
|
|
361
|
+
moved_down = 0
|
|
362
|
+
moved_up = 0
|
|
363
|
+
|
|
364
|
+
# TODO: check if any other type of ordering might help
|
|
365
|
+
# Since the last one that is moved up will appear at the very top and vice-versa for the down.
|
|
366
|
+
# We need the most distant ones to closer to the top so that the cluster can move to that direction next iteration
|
|
367
|
+
# but a random order might work better for the down ones since they are in the middle and the border is not a straight line
|
|
368
|
+
# nothing vanilla order 9/34
|
|
369
|
+
# @items_with_limit_violation.shuffle! 7/34 not much of help. it increases normal iteration count but decreases the loop time
|
|
370
|
+
# @items_with_limit_violation.sort_by!{ |i| i[5] } 6/34 fails .. good
|
|
371
|
+
# @items_with_limit_violation.sort_by!{ |i| -i[5] } 5/34 fails .. better
|
|
372
|
+
# 0.33sort+ and 0.66sort- #8/21 fails ... bad
|
|
373
|
+
# 0.33shuffle and 0.66sort- #7/20 fails ... bad
|
|
374
|
+
|
|
375
|
+
@items_with_limit_violation.sort_by!{ |i| -i[5] }
|
|
376
|
+
|
|
377
|
+
until @items_with_limit_violation.empty? do
|
|
378
|
+
data = @items_with_limit_violation.pop
|
|
379
|
+
|
|
380
|
+
# TODO: check the effectiveness of the following stochastic condition.
|
|
381
|
+
# Specifically, moving the "up" ones always would be better...?
|
|
382
|
+
# But the downs are the really problematic ones so moving them would make sense too.
|
|
383
|
+
# Tested some options but it looks alright as it is.. Needs more testing.
|
|
384
|
+
|
|
385
|
+
# if the limt violation leads to more than 2-5 times distance increase, move it
|
|
386
|
+
# otherwise, move it with a probability correlated to the detour it generates
|
|
387
|
+
if !data[0][4][:needs_to_stay_at_the_top] && (data[2] > [2 * mean_ratio.to_f, 5].min || rand < (data[1] / (3 * mean_distance_diff + 1e-10)))
|
|
388
|
+
point = data[0][0..1]
|
|
389
|
+
centroid_with_violation = @centroids[data[3]][0..1]
|
|
390
|
+
centroid_witout_violation = data[4] && @centroids[data[4]][0..1]
|
|
391
|
+
if centroid_witout_violation && Helper.check_if_projection_inside_the_line_segment(point, centroid_with_violation, centroid_witout_violation, 0.1)
|
|
392
|
+
moved_down += 1
|
|
393
|
+
data[0][4][:moved_down] = true
|
|
394
|
+
data[0][4][:centroid_weights][:limit].collect!{ |w| [w * 2, [@expected_n_visits / 10, 5].max].min } # TODO: a better mechanism ?
|
|
395
|
+
data[0][4][:centroid_weights][:limit][data[3]] = 1
|
|
396
|
+
@data_set.data_items.insert(@data_set.data_items.size - 1, @data_set.data_items.delete(data[0]))
|
|
397
|
+
else
|
|
398
|
+
moved_up += 1
|
|
399
|
+
data[0][4][:moved_up] = true if centroid_witout_violation
|
|
400
|
+
@data_set.data_items.insert(@needs_to_stay_at_the_top, @data_set.data_items.delete(data[0]))
|
|
401
|
+
end
|
|
402
|
+
end
|
|
403
|
+
end
|
|
404
|
+
if @limit_violation_count.positive?
|
|
405
|
+
@logger&.debug "Decisions taken due to capacity violation for #{@limit_violation_count} items: #{moved_down} of them moved_down, #{moved_up} of them moved_up, #{@limit_violation_count - moved_down - moved_up} of them untouched"
|
|
406
|
+
@logger&.debug "#{@clusters_with_limit_violation.count(&:any?)} clusters have limit violation (order): #{@clusters_with_limit_violation.collect.with_index{ |array, i| array.empty? ? ' _ ' : "|#{i + 1}|" }.join(' ')}" if @number_of_clusters <= 10
|
|
407
|
+
@logger&.debug "#{@clusters_with_limit_violation.count(&:any?)} clusters have limit violation (index): #{@clusters_with_limit_violation.collect.with_index{ |array, i| array.empty? ? nil : i}.compact.join(', ')}" if @number_of_clusters > 10
|
|
408
|
+
end
|
|
409
|
+
end
|
|
410
|
+
|
|
411
|
+
def recompute_centroids
|
|
412
|
+
move_limit_violating_dataitems
|
|
413
|
+
|
|
414
|
+
@old_centroids_lat_lon = @centroids.collect{ |centroid| [centroid[0], centroid[1]] }
|
|
415
|
+
|
|
416
|
+
centroid_smoothing_coeff = 0.1 + 0.9 * (@iteration / @max_iterations.to_f)**0.5
|
|
417
|
+
|
|
418
|
+
@centroids.each_with_index{ |centroid, index|
|
|
419
|
+
next if @clusters[index].data_items.empty?
|
|
420
|
+
|
|
421
|
+
# Smooth the centroid movement (and keeps track of the "history") with the previous centroid (to prevent erratic jumps)
|
|
422
|
+
# Calculates the new centroid with weighted mean (using the number of visits and distance to current centroid as a weight)
|
|
423
|
+
# That's what matters the most (how many times we have to go to a zone an how far this zone is)
|
|
424
|
+
total_weighted_visit_distance = 0
|
|
425
|
+
@clusters[index].data_items.each{ |d_i|
|
|
426
|
+
distance_weight = if d_i[4][:moved_up]
|
|
427
|
+
0.1
|
|
428
|
+
elsif d_i[4][:moved_down]
|
|
429
|
+
10
|
|
430
|
+
else
|
|
431
|
+
1
|
|
432
|
+
end
|
|
433
|
+
|
|
434
|
+
d_i[4][:weighted_visit_distance] = distance_weight * (Helper.flying_distance(centroid, d_i) + 1)**0.2 * d_i[3][:visits]**0.2 * d_i[4][:centroid_weights][:compatibility] * d_i[4][:centroid_weights][:limit][index]
|
|
435
|
+
d_i[4][:moved_up] = d_i[4][:moved_down] = nil
|
|
436
|
+
total_weighted_visit_distance += d_i[4][:weighted_visit_distance]
|
|
437
|
+
}
|
|
438
|
+
centroid[0] = centroid_smoothing_coeff * centroid[0] + (1.0 - centroid_smoothing_coeff) * @clusters[index].data_items.sum{ |d_i| d_i[0] * d_i[4][:weighted_visit_distance] } / total_weighted_visit_distance.to_f
|
|
439
|
+
centroid[1] = centroid_smoothing_coeff * centroid[1] + (1.0 - centroid_smoothing_coeff) * @clusters[index].data_items.sum{ |d_i| d_i[1] * d_i[4][:weighted_visit_distance] } / total_weighted_visit_distance.to_f
|
|
440
|
+
|
|
441
|
+
# A selector which selects closest point to represent the centroid which is the most "representative" in terms of distace to the
|
|
442
|
+
point_closest_to_centroid_center = clusters[index].data_items.min_by([[(@clusters[index].data_items.size / 10.0).ceil, 5].min, 2].max){ |data_point|
|
|
443
|
+
Helper.flying_distance(centroid, data_point)
|
|
444
|
+
}.min_by{ |data_point|
|
|
445
|
+
clusters[index].data_items.sum{ |d_i| @distance_function.call(data_point, d_i) * d_i[3][:visits] } # / total_visits_in_cluster.to_f
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
# register the id and matrix_index of the point representing the centroid
|
|
449
|
+
centroid[2] = point_closest_to_centroid_center[2].dup # id TODO: Check if dup necessary ?
|
|
450
|
+
# correct the matrix_index of the centroid with the index of the point_closest_to_centroid_center
|
|
451
|
+
centroid[4][:matrix_index] = point_closest_to_centroid_center[4][:matrix_index] if centroid[4][:matrix_index]
|
|
452
|
+
|
|
453
|
+
# correct the distance_from_and_to_depot info of the new cluster with the average of the points
|
|
454
|
+
centroid[4][:duration_from_and_to_depot] = @clusters[index].data_items.map{ |d| d[4][:duration_from_and_to_depot][index] }.reduce(&:+) / @clusters[index].data_items.size.to_f if centroid[4][:duration_from_and_to_depot]
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
swap_a_centroid_with_limit_violation
|
|
458
|
+
|
|
459
|
+
@iteration += 1
|
|
460
|
+
end
|
|
461
|
+
|
|
462
|
+
def swap_a_centroid_with_limit_violation
|
|
463
|
+
already_swapped_a_centroid = false
|
|
464
|
+
@clusters_with_limit_violation.map.with_index.sort_by{ |arr, _i| -arr.size }.each{ |preferred_clusters, violated_cluster|
|
|
465
|
+
if preferred_clusters.empty?
|
|
466
|
+
@centroids[violated_cluster][4][:capacity_offence_coeff] -= 0.3 if @centroids[violated_cluster][4][:capacity_offence_coeff] > 0.3
|
|
467
|
+
next
|
|
468
|
+
end
|
|
469
|
+
|
|
470
|
+
# TODO: elimination/determination of units per cluster should be done at the beginning
|
|
471
|
+
# Each centroid should know what matters to them.
|
|
472
|
+
the_units_that_matter = @centroids[violated_cluster][3].select{ |i, v| i && v.positive? }.keys & @strict_limitations[violated_cluster].select{ |i, v| i && v }.keys
|
|
473
|
+
|
|
474
|
+
# TODO: only consider the clusters that are "compatible" with this cluster -- i.e., they can serve the points of this cluster and vice-versa
|
|
475
|
+
favorite_clusters = @centroids.map.with_index.select{ |c, _i|
|
|
476
|
+
the_units_that_matter.any?{ |unit|
|
|
477
|
+
limit = c[4][:capacities][unit]
|
|
478
|
+
!@centroids[violated_cluster][4][:capacities][unit].nil? && (limit.nil? || limit > @centroids[violated_cluster][4][:capacities][unit])
|
|
479
|
+
}
|
|
480
|
+
}.select{ |c, i|
|
|
481
|
+
clusters_can_be_swapped = the_units_that_matter.all?{ |unit| # cluster to be swapped should
|
|
482
|
+
(@strict_limitations[violated_cluster][unit].nil? || c[3][unit] < 0.98 * @strict_limitations[violated_cluster][unit]) && # be less loaded
|
|
483
|
+
(@strict_limitations[i][unit].nil? || @strict_limitations[i][unit] >= 1.02 * @centroids[violated_cluster][3][unit]) && # have more limit
|
|
484
|
+
@clusters[violated_cluster].data_items.all?{ |d_i| @compatibility_function.call(d_i, @centroids[i]) } &&
|
|
485
|
+
@clusters[i].data_items.all?{ |d_i| @compatibility_function.call(d_i, @centroids[violated_cluster]) }
|
|
486
|
+
} # and they should be able to serve eachothers's points
|
|
487
|
+
|
|
488
|
+
next unless clusters_can_be_swapped
|
|
489
|
+
|
|
490
|
+
# check if swapping the centroids is okay from multi-depot point of view
|
|
491
|
+
violated_duration_from_and_to_depot = @clusters[violated_cluster].data_items.sum{ |d| d[4][:duration_from_and_to_depot][i] } / @clusters[violated_cluster].data_items.size.to_f
|
|
492
|
+
favorite_duration_from_and_to_depot = @clusters[i].data_items.sum{ |d| d[4][:duration_from_and_to_depot][violated_cluster] } / @clusters[i].data_items.size.to_f
|
|
493
|
+
|
|
494
|
+
(violated_duration_from_and_to_depot < @centroids[violated_cluster][4][:duration_from_and_to_depot] ||
|
|
495
|
+
favorite_duration_from_and_to_depot < @centroids[i][4][:duration_from_and_to_depot]) ||
|
|
496
|
+
(violated_duration_from_and_to_depot < 2 * @centroids[violated_cluster][4][:duration_from_and_to_depot] &&
|
|
497
|
+
favorite_duration_from_and_to_depot < 2 * @centroids[i][4][:duration_from_and_to_depot])
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
if favorite_clusters.empty?
|
|
501
|
+
@logger&.debug "cannot swap #{violated_cluster + 1}th cluster due compatibility, increasing its balance_coeff"
|
|
502
|
+
@balance_coeff[violated_cluster] /= 0.95 # increase the coefficient of the violated cluster
|
|
503
|
+
|
|
504
|
+
@centroids[violated_cluster][4][:capacity_offence_coeff] += 1
|
|
505
|
+
|
|
506
|
+
# TODO: this coefficient can be made dependent to
|
|
507
|
+
# the number of times this cluster refused an item
|
|
508
|
+
# or the total "load" of all rejected items
|
|
509
|
+
# or the item that has the smallest items_with_limit_violation[3] (ratio)...
|
|
510
|
+
# and a coeff that makes sure that this cluster will not be the closest in the next itration
|
|
511
|
+
next
|
|
512
|
+
end
|
|
513
|
+
|
|
514
|
+
next if already_swapped_a_centroid
|
|
515
|
+
|
|
516
|
+
favorite_cluster = favorite_clusters.min_by{ |c, index|
|
|
517
|
+
the_units_that_matter.sum{ |unit|
|
|
518
|
+
if @strict_limitations[index][unit].nil?
|
|
519
|
+
0
|
|
520
|
+
else
|
|
521
|
+
# give others some chance with randomness
|
|
522
|
+
c[3][unit].to_f / @strict_limitations[index][unit] * (rand(0.90) + 0.1)
|
|
523
|
+
end
|
|
524
|
+
}
|
|
525
|
+
}[1] # index of the minimum
|
|
526
|
+
|
|
527
|
+
# TODO: we need to understand why the folling if condition happens:
|
|
528
|
+
# It is probably due to missing units in capacity -- which lead to
|
|
529
|
+
# the same point "rejected" by every cluster and this
|
|
530
|
+
# makes them get labeled "violated"
|
|
531
|
+
next if favorite_cluster == violated_cluster
|
|
532
|
+
|
|
533
|
+
swap_safe = [
|
|
534
|
+
@centroids[violated_cluster][0..2],
|
|
535
|
+
@centroids[violated_cluster][4][:matrix_index],
|
|
536
|
+
@centroids[violated_cluster][4][:duration_from_and_to_depot],
|
|
537
|
+
@balance_coeff[violated_cluster]
|
|
538
|
+
] # lat lon point_id and matrix_index duration_from_and_to_depot if exists
|
|
539
|
+
|
|
540
|
+
@centroids[violated_cluster][0..2] = @centroids[favorite_cluster][0..2]
|
|
541
|
+
@centroids[violated_cluster][4][:matrix_index] = @centroids[favorite_cluster][4][:matrix_index]
|
|
542
|
+
@centroids[violated_cluster][4][:duration_from_and_to_depot] = @centroids[favorite_cluster][4][:duration_from_and_to_depot]
|
|
543
|
+
@balance_coeff[violated_cluster] = @balance_coeff[favorite_cluster]
|
|
544
|
+
|
|
545
|
+
@centroids[favorite_cluster][0..2] = swap_safe[0]
|
|
546
|
+
@centroids[favorite_cluster][4][:matrix_index] = swap_safe[1]
|
|
547
|
+
@centroids[favorite_cluster][4][:duration_from_and_to_depot] = swap_safe[2]
|
|
548
|
+
@balance_coeff[favorite_cluster] = swap_safe[3]
|
|
549
|
+
|
|
550
|
+
@logger&.debug "swapped location of #{violated_cluster + 1}th cluster with #{favorite_cluster + 1}th cluster"
|
|
551
|
+
already_swapped_a_centroid = true # break # swap only one at a time
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
@clusters_with_limit_violation.each(&:clear)
|
|
555
|
+
end
|
|
556
|
+
|
|
557
|
+
# Classifies the given data item, returning the cluster index it belongs
|
|
558
|
+
# to (0-based).
|
|
559
|
+
def evaluate(data_item)
|
|
560
|
+
distances = @evaluate_distance_buffer
|
|
561
|
+
@centroids.each_with_index do |centroid, cluster_index|
|
|
562
|
+
dist = distance(data_item, centroid, cluster_index)
|
|
563
|
+
dist += INCOMPATIBILITY_DISTANCE_PENALTY unless @compatibility_function.call(data_item, centroid)
|
|
564
|
+
distances[cluster_index] = dist
|
|
565
|
+
end
|
|
566
|
+
|
|
567
|
+
closest_cluster_index = get_min_index(distances)
|
|
568
|
+
|
|
569
|
+
if capactity_violation?(data_item, closest_cluster_index)
|
|
570
|
+
mininimum_without_limit_violation = INCOMPATIBILITY_DISTANCE_PENALTY
|
|
571
|
+
closest_cluster_wo_violation_index = nil
|
|
572
|
+
@number_of_clusters.times do |k|
|
|
573
|
+
next unless distances[k] < mininimum_without_limit_violation &&
|
|
574
|
+
!capactity_violation?(data_item, k)
|
|
575
|
+
|
|
576
|
+
closest_cluster_wo_violation_index = k
|
|
577
|
+
mininimum_without_limit_violation = distances[k]
|
|
578
|
+
end
|
|
579
|
+
|
|
580
|
+
if closest_cluster_wo_violation_index
|
|
581
|
+
mininimum_with_limit_violation = distances.min
|
|
582
|
+
diff = mininimum_without_limit_violation - mininimum_with_limit_violation
|
|
583
|
+
ratio = mininimum_without_limit_violation / mininimum_with_limit_violation
|
|
584
|
+
ratio = 1 if ratio.to_f.nan?
|
|
585
|
+
|
|
586
|
+
@items_with_limit_violation << [
|
|
587
|
+
data_item, diff, ratio, closest_cluster_index, closest_cluster_wo_violation_index, mininimum_with_limit_violation
|
|
588
|
+
]
|
|
589
|
+
|
|
590
|
+
@clusters_with_limit_violation[closest_cluster_index] << closest_cluster_wo_violation_index
|
|
591
|
+
closest_cluster_index = closest_cluster_wo_violation_index
|
|
592
|
+
end
|
|
593
|
+
end
|
|
594
|
+
|
|
595
|
+
closest_cluster_index
|
|
596
|
+
end
|
|
597
|
+
|
|
598
|
+
protected
|
|
599
|
+
|
|
600
|
+
def distance(data_item, centroid, cluster_index)
|
|
601
|
+
total_dist = 0
|
|
602
|
+
|
|
603
|
+
do_forall_linked_items_of(data_item){ |linked_item| total_dist += @distance_function.call(linked_item, centroid) }
|
|
604
|
+
|
|
605
|
+
total_dist * @balance_coeff[cluster_index]
|
|
606
|
+
end
|
|
607
|
+
|
|
608
|
+
def calculate_membership_clusters
|
|
609
|
+
update_strict_duration_limitation_wrt_depot
|
|
610
|
+
|
|
611
|
+
@centroids.each{ |centroid| centroid[3] = Hash.new(0) }
|
|
612
|
+
@cluster_load.each(&:clear)
|
|
613
|
+
@clusters = Array.new(@number_of_clusters) do
|
|
614
|
+
Ai4r::Data::DataSet.new data_labels: @data_set.data_labels
|
|
615
|
+
end
|
|
616
|
+
|
|
617
|
+
@already_assigned = Hash.new{ |h, k| h[k] = false }
|
|
618
|
+
|
|
619
|
+
@data_set.data_items.each{ |data_item|
|
|
620
|
+
next if @already_assigned[data_item] # another item with a relation handled this item
|
|
621
|
+
|
|
622
|
+
cluster_index = evaluate(data_item)
|
|
623
|
+
|
|
624
|
+
do_forall_linked_items_of(data_item){ |linked_item|
|
|
625
|
+
assign_item(linked_item, cluster_index)
|
|
626
|
+
update_metrics(linked_item, cluster_index)
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
manage_empty_clusters
|
|
631
|
+
end
|
|
632
|
+
|
|
633
|
+
def assign_item(data_item, cluster_index)
|
|
634
|
+
@already_assigned[data_item] = true
|
|
635
|
+
@clusters[cluster_index] << data_item
|
|
636
|
+
end
|
|
637
|
+
|
|
638
|
+
def calc_initial_centroids
|
|
639
|
+
@centroids, @old_centroids_lat_lon, @remaining_skills = [], nil, @vehicles.dup
|
|
640
|
+
if @centroid_indices.empty?
|
|
641
|
+
populate_centroids('random')
|
|
642
|
+
else
|
|
643
|
+
populate_centroids('indices')
|
|
644
|
+
end
|
|
645
|
+
@centroids.each{ |c|
|
|
646
|
+
c[4][:capacity_offence_coeff] = 0
|
|
647
|
+
c[4][:route_time] = 0
|
|
648
|
+
}
|
|
649
|
+
end
|
|
650
|
+
|
|
651
|
+
def populate_centroids(populate_method, number_of_clusters = @number_of_clusters)
|
|
652
|
+
# Generate centroids based on remaining_skills available
|
|
653
|
+
# Similarly with data_items, each centroid is defined by :
|
|
654
|
+
# index 0 : latitude
|
|
655
|
+
# index 1 : longitude
|
|
656
|
+
# index 2 : item_id
|
|
657
|
+
# index 3 : unit_fullfillment -> for each unit, quantity contained in corresponding cluster
|
|
658
|
+
# index 4 : characterisits -> { v_id: sticky_vehicle_ids, skills: skills, day_skills: day_skills, matrix_index: matrix_index }
|
|
659
|
+
raise ArgumentError, 'No vehicles provided' if @remaining_skills.nil?
|
|
660
|
+
|
|
661
|
+
case populate_method
|
|
662
|
+
when 'random'
|
|
663
|
+
while @centroids.length < number_of_clusters
|
|
664
|
+
available_items ||= @data_set.data_items.dup # get a new container object
|
|
665
|
+
|
|
666
|
+
skills = @remaining_skills.shift
|
|
667
|
+
|
|
668
|
+
# Select from the items which are not already used which
|
|
669
|
+
# specifically need the skill set of this cluster
|
|
670
|
+
# Prefer items whom closest depot corresponds to current cluster.
|
|
671
|
+
items_to_consider = available_items.select{ |item|
|
|
672
|
+
!item[4].empty? &&
|
|
673
|
+
!(item[4][:v_id].empty? && item[4][:skills].empty?) &&
|
|
674
|
+
@compatibility_function.call(item, [0, 0, 0, 0, skills, 0])
|
|
675
|
+
}
|
|
676
|
+
compatible_items = compatible_items_multi_depot_selector(items_to_consider)
|
|
677
|
+
|
|
678
|
+
# If there are no items which specifically needs these skills,
|
|
679
|
+
# then find all the items that can be assigned to this cluster.
|
|
680
|
+
# Prefer items whom closest depot corresponds to current cluster.
|
|
681
|
+
if compatible_items.empty?
|
|
682
|
+
items_to_consider = available_items.select{ |item|
|
|
683
|
+
@compatibility_function.call(item, [0, 0, 0, 0, skills, 0])
|
|
684
|
+
}
|
|
685
|
+
compatible_items = compatible_items_multi_depot_selector(items_to_consider)
|
|
686
|
+
end
|
|
687
|
+
|
|
688
|
+
# If, still, there are no items that can be assigned to this cluster
|
|
689
|
+
# initialize it with a random point
|
|
690
|
+
# Prefer items whom closest depot corresponds to current cluster.
|
|
691
|
+
compatible_items = compatible_items_multi_depot_selector(available_items) if compatible_items.empty?
|
|
692
|
+
|
|
693
|
+
# If, still empty (!) there are more clusters then items so
|
|
694
|
+
# initialize it at a random point
|
|
695
|
+
compatible_items = compatible_items_multi_depot_selector(@data_set.data_items) if compatible_items.empty?
|
|
696
|
+
|
|
697
|
+
item = compatible_items[rand(compatible_items.size)]
|
|
698
|
+
|
|
699
|
+
skills[:matrix_index] = item[4][:matrix_index]
|
|
700
|
+
skills[:duration_from_and_to_depot] = item[4][:duration_from_and_to_depot][@centroids.length]
|
|
701
|
+
@centroids << [item[0], item[1], item[2], Hash.new(0), skills]
|
|
702
|
+
|
|
703
|
+
do_forall_linked_items_of(item){ |linked_item| available_items.delete(linked_item) }
|
|
704
|
+
|
|
705
|
+
@data_set.data_items.insert(0, @data_set.data_items.delete(item))
|
|
706
|
+
end
|
|
707
|
+
when 'indices' # for initial assignment only (with the :centroid_indices option)
|
|
708
|
+
raise ArgumentError, 'Same centroid_index provided several times' if @centroid_indices.size != @centroid_indices.uniq.size
|
|
709
|
+
|
|
710
|
+
raise ArgumentError, 'Wrong number of initial centroids provided' if @centroid_indices.size != @number_of_clusters
|
|
711
|
+
|
|
712
|
+
insert_at_begining = []
|
|
713
|
+
@centroid_indices.each_with_index do |index, ind|
|
|
714
|
+
raise ArgumentError, 'Invalid centroid index' unless (index.is_a? Integer) && index >= 0 && index < @data_set.data_items.length
|
|
715
|
+
|
|
716
|
+
skills = @remaining_skills.shift
|
|
717
|
+
item = @data_set.data_items[index]
|
|
718
|
+
|
|
719
|
+
# check if linked data items are assigned to different centroids
|
|
720
|
+
do_forall_linked_items_of(item){ |linked_item|
|
|
721
|
+
msg = "Centroid #{ind} is initialised with a service which has a linked service that is used to initialise centroid #{insert_at_begining.index(linked_item)}"
|
|
722
|
+
raise ArgumentError, msg if insert_at_begining.include?(linked_item)
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
raise ArgumentError, "Centroid #{ind} is initialised with an incompatible service -- #{index}" unless @compatibility_function.call(item, [nil, nil, nil, nil, skills])
|
|
726
|
+
|
|
727
|
+
skills[:matrix_index] = item[4][:matrix_index]
|
|
728
|
+
skills[:duration_from_and_to_depot] = item[4][:duration_from_and_to_depot][@centroids.length]
|
|
729
|
+
@centroids << [item[0], item[1], item[2], Hash.new(0), skills]
|
|
730
|
+
|
|
731
|
+
insert_at_begining << item
|
|
732
|
+
end
|
|
733
|
+
|
|
734
|
+
insert_at_begining.each{ |i|
|
|
735
|
+
@data_set.data_items.insert(0, @data_set.data_items.delete(i))
|
|
736
|
+
}
|
|
737
|
+
end
|
|
738
|
+
@number_of_clusters = @centroids.length
|
|
739
|
+
end
|
|
740
|
+
|
|
741
|
+
def manage_empty_clusters
|
|
742
|
+
return unless @clusters.any? { |cluster| cluster.data_items.empty? }
|
|
743
|
+
|
|
744
|
+
@clusters.each_with_index{ |empty_cluster, ind|
|
|
745
|
+
next unless empty_cluster.data_items.empty?
|
|
746
|
+
|
|
747
|
+
empty_centroid = @centroids[ind]
|
|
748
|
+
|
|
749
|
+
distances = @clusters.collect{ |cluster|
|
|
750
|
+
next unless cluster.data_items.size > 1
|
|
751
|
+
|
|
752
|
+
min_distance = Float::INFINITY
|
|
753
|
+
|
|
754
|
+
closest_item = cluster.data_items.select{ |d_i|
|
|
755
|
+
@compatibility_function.call(d_i, empty_centroid)
|
|
756
|
+
}.min_by{ |d_i|
|
|
757
|
+
total_dist = 0
|
|
758
|
+
|
|
759
|
+
do_forall_linked_items_of(d_i){ |linked_item| total_dist += @distance_function.call(linked_item, empty_centroid) }
|
|
760
|
+
|
|
761
|
+
min_distance = [total_dist, min_distance].min
|
|
762
|
+
|
|
763
|
+
total_dist
|
|
764
|
+
}
|
|
765
|
+
next if closest_item.nil?
|
|
766
|
+
|
|
767
|
+
[min_distance, closest_item, cluster]
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
closest = distances.min_by{ |d| d.nil? ? Float::INFINITY : d[0] }
|
|
771
|
+
|
|
772
|
+
next if closest.nil?
|
|
773
|
+
|
|
774
|
+
do_forall_linked_items_of(closest[1]){ |linked_item| empty_cluster.data_items << closest[2].data_items.delete(linked_item) }
|
|
775
|
+
}
|
|
776
|
+
end
|
|
777
|
+
|
|
778
|
+
def stop_criteria_met
|
|
779
|
+
centroids_converged_or_in_loop(Math.sqrt(@iteration).to_i) && # This check should stay first since it keeps track of the centroid movements..
|
|
780
|
+
@limit_violation_count.zero? && # Do not converge if a decision is taken due to limit violation.
|
|
781
|
+
@max_balance_violation.to_f <= 0.05 + (@iteration.to_f / @max_iterations)**8
|
|
782
|
+
end
|
|
783
|
+
|
|
784
|
+
private
|
|
785
|
+
|
|
786
|
+
def calculate_local_speeds
|
|
787
|
+
# TODO: Following speed approximation is done with respect to a fixed depot
|
|
788
|
+
# (first one) it needs to be generalised to multi-depot case...
|
|
789
|
+
local_max_speed = 60 / 3.6 # meters per second
|
|
790
|
+
local_min_speed = 5 / 3.6 # meters per second
|
|
791
|
+
items = @data_set.data_items
|
|
792
|
+
grid = build_local_speed_spatial_grid(items)
|
|
793
|
+
|
|
794
|
+
items.each do |a|
|
|
795
|
+
next if a[4][:local_speed]
|
|
796
|
+
|
|
797
|
+
a[4][:local_speed] = compute_local_speed_for_item(a, grid, local_min_speed, local_max_speed)
|
|
798
|
+
end
|
|
799
|
+
end
|
|
800
|
+
|
|
801
|
+
def build_local_speed_spatial_grid(items)
|
|
802
|
+
grid = Hash.new { |h, k| h[k] = [] }
|
|
803
|
+
cell = LOCAL_SPEED_CELL_DEG
|
|
804
|
+
items.each do |item|
|
|
805
|
+
grid[[(item[0] / cell).to_i, (item[1] / cell).to_i]] << item
|
|
806
|
+
end
|
|
807
|
+
grid
|
|
808
|
+
end
|
|
809
|
+
|
|
810
|
+
def local_speed_spatial_neighbors(item, grid)
|
|
811
|
+
cell = LOCAL_SPEED_CELL_DEG
|
|
812
|
+
cx = (item[0] / cell).to_i
|
|
813
|
+
cy = (item[1] / cell).to_i
|
|
814
|
+
neighbors = []
|
|
815
|
+
(-1..1).each do |dx|
|
|
816
|
+
(-1..1).each do |dy|
|
|
817
|
+
neighbors.concat(grid[[cx + dx, cy + dy]])
|
|
818
|
+
end
|
|
819
|
+
end
|
|
820
|
+
neighbors
|
|
821
|
+
end
|
|
822
|
+
|
|
823
|
+
def compute_local_speed_for_item(item, grid, local_min_speed, local_max_speed)
|
|
824
|
+
depot_duration_a = item[4][:duration_from_and_to_depot][0]
|
|
825
|
+
candidates = []
|
|
826
|
+
local_speed_spatial_neighbors(item, grid).each do |other|
|
|
827
|
+
next if other.equal?(item)
|
|
828
|
+
|
|
829
|
+
depot_diff = (depot_duration_a - other[4][:duration_from_and_to_depot][0]).abs
|
|
830
|
+
next unless depot_diff > 1
|
|
831
|
+
|
|
832
|
+
flying_dist = Helper.flying_distance(item, other)
|
|
833
|
+
next unless flying_dist > 5
|
|
834
|
+
|
|
835
|
+
candidates << [flying_dist, other]
|
|
836
|
+
end
|
|
837
|
+
|
|
838
|
+
calculated_speed = if candidates.empty?
|
|
839
|
+
local_min_speed
|
|
840
|
+
else
|
|
841
|
+
candidates.sort_by!(&:first)
|
|
842
|
+
candidates.first(LOCAL_SPEED_NEIGHBOR_LIMIT).map { |flying_dist, other|
|
|
843
|
+
flying_dist / (depot_duration_a - other[4][:duration_from_and_to_depot][0]).abs
|
|
844
|
+
}.min(2).sum * 1.1
|
|
845
|
+
end
|
|
846
|
+
|
|
847
|
+
[[calculated_speed, local_min_speed].max, local_max_speed].min
|
|
848
|
+
end
|
|
849
|
+
|
|
850
|
+
def do_forall_linked_items_of(item)
|
|
851
|
+
linked_item = nil
|
|
852
|
+
until linked_item == item
|
|
853
|
+
linked_item = (linked_item && linked_item[4][:linked_item]) || item[4][:linked_item] || item
|
|
854
|
+
yield(linked_item)
|
|
855
|
+
end
|
|
856
|
+
end
|
|
857
|
+
|
|
858
|
+
def mark_the_items_which_needs_to_stay_at_the_top
|
|
859
|
+
@data_set.data_items.each{ |i| i[4][:needs_to_stay_at_the_top] = false }
|
|
860
|
+
@vehicles.flat_map{ |c| c[:capacities].keys }.uniq.each{ |unit|
|
|
861
|
+
@data_set.data_items.max_by(@data_set.data_items.size * 0.005 + 1){ |i| i[3][unit].to_f }.each{ |data_item|
|
|
862
|
+
data_item[4][:needs_to_stay_at_the_top] = true if data_item[3][unit]
|
|
863
|
+
}
|
|
864
|
+
}
|
|
865
|
+
@needs_to_stay_at_the_top = @data_set.data_items.count{ |i| i[4][:needs_to_stay_at_the_top] }
|
|
866
|
+
|
|
867
|
+
@data_set.data_items.select{ |i| i[4][:needs_to_stay_at_the_top] }.each{ |item|
|
|
868
|
+
@data_set.data_items.insert(0, @data_set.data_items.delete(item))
|
|
869
|
+
}
|
|
870
|
+
end
|
|
871
|
+
|
|
872
|
+
def updated_visit_densities
|
|
873
|
+
# n_visits/m^2
|
|
874
|
+
@centroids.collect.with_index{ |centroid, index|
|
|
875
|
+
next if @clusters[index].data_items.empty?
|
|
876
|
+
|
|
877
|
+
cp = centroid[4] # centroid properties
|
|
878
|
+
cp[:area] = [Helper.approximate_polygon_area(Helper.approximate_quadrilateral_polygon(@clusters[index].data_items)), 1.0].max # m^2
|
|
879
|
+
cp[:visit_count] = @clusters[index].data_items.sum{ |d_i| d_i[3][:visits] }
|
|
880
|
+
cp[:visit_density] = cp[:visit_count] / cp[:area].to_f
|
|
881
|
+
}
|
|
882
|
+
end
|
|
883
|
+
|
|
884
|
+
def update_approximate_area_and_speeds
|
|
885
|
+
@density_cap = [@density_cap, updated_visit_densities.compact.median * @density_range_ratio].max
|
|
886
|
+
|
|
887
|
+
@centroids.each_with_index{ |centroid, index|
|
|
888
|
+
next if @clusters[index].data_items.empty?
|
|
889
|
+
|
|
890
|
+
cp = centroid[4] # centroid properties
|
|
891
|
+
min_speed_calc_size = (0.95 * @clusters[index].data_items.size).ceil
|
|
892
|
+
max_speed_calc_size = (0.15 * @clusters[index].data_items.size).ceil
|
|
893
|
+
cp[:min_speed] = @clusters[index].data_items.min_by(min_speed_calc_size){ |i| i[4][:local_speed] }.sum{ |i| i[4][:local_speed] }.to_f / min_speed_calc_size
|
|
894
|
+
cp[:max_speed] = @clusters[index].data_items.max_by(max_speed_calc_size){ |i| i[4][:local_speed] }.sum{ |i| i[4][:local_speed] }.to_f / max_speed_calc_size
|
|
895
|
+
speed_ratio = ([@density_cap - cp[:visit_density], 0].max / @density_cap.to_f)**@density_speed_conversion_power
|
|
896
|
+
cp[:speed] = cp[:min_speed] + [cp[:max_speed] - cp[:min_speed], 0.0].max * speed_ratio
|
|
897
|
+
}
|
|
898
|
+
end
|
|
899
|
+
|
|
900
|
+
def update_approximate_route_times
|
|
901
|
+
return unless @cut_symbol
|
|
902
|
+
|
|
903
|
+
update_approximate_area_and_speeds
|
|
904
|
+
|
|
905
|
+
approximate_total_route_time = @centroids.select{ |c| c[4][:speed] }.sum{ |centroid|
|
|
906
|
+
cp = centroid[4] # centroid properties
|
|
907
|
+
cp[:route_time] = Helper.compute_approximate_route_time(cp[:area], cp[:visit_count], cp[:speed], cp[:total_work_days] / cp[:vehicle_count].to_f) * cp[:vehicle_count]
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
@approximate_total_route_time = (9 * @approximate_total_route_time + approximate_total_route_time) / 10.0 if approximate_total_route_time < @approximate_total_route_time
|
|
911
|
+
end
|
|
912
|
+
|
|
913
|
+
def compute_vehicle_work_time_with_depot_and_capacity
|
|
914
|
+
coef = @centroids.map.with_index{ |centroid, index|
|
|
915
|
+
@vehicles[index][:duration] / ([centroid[4][:duration_from_and_to_depot], 1].max * @vehicles[index][:total_work_days])
|
|
916
|
+
}.min
|
|
917
|
+
|
|
918
|
+
# TODO: The following filter is there to not to affect the existing functionality.
|
|
919
|
+
# However, we should improve the functioanlity and make it less arbitrary.
|
|
920
|
+
coef = [coef * 0.9, 1.0].min # To make sure the limit will not become 0
|
|
921
|
+
|
|
922
|
+
min_capacity_offence_coeff = @centroids.min_by{ |c| c[4][:capacity_offence_coeff] }[4][:capacity_offence_coeff]
|
|
923
|
+
|
|
924
|
+
@centroids.map.with_index{ |centroid, index|
|
|
925
|
+
centroid[4][:capacity_offence_coeff] -= min_capacity_offence_coeff
|
|
926
|
+
(@vehicles[index][:duration] - coef * centroid[4][:duration_from_and_to_depot] * @vehicles[index][:total_work_days]) * 0.98**centroid[4][:capacity_offence_coeff]
|
|
927
|
+
}
|
|
928
|
+
end
|
|
929
|
+
|
|
930
|
+
def update_cut_limit_wrt_depot_and_route_time
|
|
931
|
+
update_approximate_route_times
|
|
932
|
+
|
|
933
|
+
return unless @cut_symbol == :duration
|
|
934
|
+
|
|
935
|
+
# TODO: depot (commute) duration effects the balance differently (and more directly and strongly)
|
|
936
|
+
# Check if it is better to leave it like this or including it inside the total_route_time
|
|
937
|
+
# so that it increases the total_load and shared amongs the clusters.
|
|
938
|
+
|
|
939
|
+
vehicle_work_times = compute_vehicle_work_time_with_depot_and_capacity
|
|
940
|
+
total_vehicle_work_time = vehicle_work_times.sum
|
|
941
|
+
@centroids.size.times{ |index|
|
|
942
|
+
@cut_limit[index][:limit] = (@total_cut_load + @approximate_total_route_time) * vehicle_work_times[index] / total_vehicle_work_time
|
|
943
|
+
}
|
|
944
|
+
end
|
|
945
|
+
|
|
946
|
+
def update_strict_duration_limitation_wrt_depot
|
|
947
|
+
return unless @cut_symbol
|
|
948
|
+
|
|
949
|
+
@centroids.map.with_index{ |centroid, index|
|
|
950
|
+
@strict_limitations[index][:duration] = @vehicles[index][:duration] - centroid[4][:duration_from_and_to_depot] * @vehicles[index][:total_work_days]
|
|
951
|
+
}
|
|
952
|
+
end
|
|
953
|
+
|
|
954
|
+
def update_balance_coefficients
|
|
955
|
+
return unless @cut_symbol
|
|
956
|
+
|
|
957
|
+
update_cut_limit_wrt_depot_and_route_time
|
|
958
|
+
|
|
959
|
+
balance_violations = @cut_limit.collect.with_index{ |c_l, index|
|
|
960
|
+
# incase we want to take into account route_time for capacity checks,
|
|
961
|
+
# protect the balance calculation
|
|
962
|
+
route_time = @centroids[index][4][:route_time] if @cut_symbol == :duration
|
|
963
|
+
(@centroids[index][3][@cut_symbol] + route_time.to_f) / c_l[:limit].to_f - 1.0
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
@logger&.debug '_____________________________________________________________________________________________________________'
|
|
967
|
+
@logger&.debug balance_violations.sum.round(2)
|
|
968
|
+
@logger&.debug Helper.colorize_balance_violations(balance_violations).join(', ')
|
|
969
|
+
|
|
970
|
+
stepsize = 0.2 - 0.1 * @iteration / @max_iterations.to_f # unitless coefficient for making the updates smaller
|
|
971
|
+
max_correction = 1.05 + 0.95 * (@max_iterations - @iteration) / @max_iterations.to_f
|
|
972
|
+
min_correction = 1.0 / max_correction
|
|
973
|
+
|
|
974
|
+
@max_balance_violation = 0
|
|
975
|
+
|
|
976
|
+
@number_of_clusters.times.each{ |index|
|
|
977
|
+
# Skip capacity violating clusters even if they are under-loaded
|
|
978
|
+
# otherwise, they will violate their capacity more
|
|
979
|
+
next if !@clusters_with_limit_violation[index].empty? && balance_violations[index].negative?
|
|
980
|
+
|
|
981
|
+
@max_balance_violation = [@max_balance_violation, balance_violations[index].abs].max # ignores capacity violating clusters!
|
|
982
|
+
|
|
983
|
+
# TODO: if the violation is small don't bother updating the coeff ?
|
|
984
|
+
# next if balance_violations[index].abs < @balance_violation_current_limit
|
|
985
|
+
|
|
986
|
+
balance_correction = [[(1 + balance_violations[index])**stepsize, min_correction].max, max_correction].min
|
|
987
|
+
|
|
988
|
+
# TODO: stepsize can evolve during the iterations like column-generation
|
|
989
|
+
# it shouldn't lead to extreme zig-zagging but it shouldn't lead to
|
|
990
|
+
# flat-lining either.
|
|
991
|
+
|
|
992
|
+
@balance_coeff[index] *= balance_correction
|
|
993
|
+
@balance_coeff.collect!{ |b_c| b_c / balance_correction }
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
# TODO: check if there is a better way to do stabilization
|
|
997
|
+
# make balance coeff mean 1 to prevent them getting extremely big/small
|
|
998
|
+
stabilization_coeff = 1.0 / @balance_coeff.mean
|
|
999
|
+
@balance_coeff.collect!{ |b_c| stabilization_coeff * b_c }
|
|
1000
|
+
|
|
1001
|
+
@logger&.debug "new balance_coeffs:\n#{@balance_coeff.collect{ |b_c| b_c.round(3) }.join(', ')}"
|
|
1002
|
+
end
|
|
1003
|
+
|
|
1004
|
+
def centroids_converged_or_in_loop(last_n_iterations)
|
|
1005
|
+
# Checks if there is a loop of size last_n_iterations
|
|
1006
|
+
if @iteration.zero?
|
|
1007
|
+
# Initialize the array stats array
|
|
1008
|
+
@last_n_average_diffs = [0.0] * (2 * last_n_iterations + 1)
|
|
1009
|
+
return false
|
|
1010
|
+
end
|
|
1011
|
+
|
|
1012
|
+
# Calculate total absolute centroid movement in meters
|
|
1013
|
+
total_movement_meter = 0
|
|
1014
|
+
@number_of_clusters.times { |i|
|
|
1015
|
+
total_movement_meter += Helper.euclidean_distance(@old_centroids_lat_lon[i], @centroids[i])
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
@logger&.debug "Iteration #{@iteration}: total centroid movement #{total_movement_meter.round} eucledian meters"
|
|
1019
|
+
|
|
1020
|
+
@last_n_average_diffs.push total_movement_meter.to_f # add to the vector before convergence check in case other conditions are not satisfied
|
|
1021
|
+
|
|
1022
|
+
# If convereged, we can stop
|
|
1023
|
+
return true if @last_n_average_diffs.last < @number_of_clusters * (20 + 80 * @iteration / @max_iterations)
|
|
1024
|
+
|
|
1025
|
+
# Check if there is a centroid loop of size n
|
|
1026
|
+
(1..last_n_iterations).each{ |n|
|
|
1027
|
+
last_n_iter_average_curr = @last_n_average_diffs[-n..-1].reduce(:+)
|
|
1028
|
+
last_n_iter_average_prev = @last_n_average_diffs[-(n + n)..-(1 + n)].reduce(:+)
|
|
1029
|
+
|
|
1030
|
+
# If we make exact same moves again and again, we can stop
|
|
1031
|
+
return true if (last_n_iter_average_curr - last_n_iter_average_prev).abs < 1e-5
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
# Clean old stats
|
|
1035
|
+
@last_n_average_diffs.shift if @last_n_average_diffs.size > (2 * last_n_iterations + 1)
|
|
1036
|
+
|
|
1037
|
+
false
|
|
1038
|
+
end
|
|
1039
|
+
|
|
1040
|
+
def update_metrics(data_item, cluster_index)
|
|
1041
|
+
data_item[3].each do |unit, value|
|
|
1042
|
+
quantity = value.to_f
|
|
1043
|
+
@centroids[cluster_index][3][unit] += quantity
|
|
1044
|
+
@cluster_load[cluster_index][unit] += quantity
|
|
1045
|
+
end
|
|
1046
|
+
end
|
|
1047
|
+
|
|
1048
|
+
def linked_quantity(item, unit)
|
|
1049
|
+
total_value = 0.0
|
|
1050
|
+
do_forall_linked_items_of(item) { |linked_item| total_value += linked_item[3][unit].to_f if linked_item[3][unit] }
|
|
1051
|
+
total_value
|
|
1052
|
+
end
|
|
1053
|
+
|
|
1054
|
+
def capactity_violation?(item, cluster_index)
|
|
1055
|
+
# TODO: correction of duration wrt to cluster "size" is needed for duration capacity
|
|
1056
|
+
@strict_limitations[cluster_index].any? do |unit, limit|
|
|
1057
|
+
next false unless limit
|
|
1058
|
+
|
|
1059
|
+
@cluster_load[cluster_index][unit] + linked_quantity(item, unit) > limit
|
|
1060
|
+
end
|
|
1061
|
+
end
|
|
1062
|
+
|
|
1063
|
+
def compatible_items_multi_depot_selector(items_to_consider)
|
|
1064
|
+
closest_items = []
|
|
1065
|
+
@vehicles.length.times.each{ |margin|
|
|
1066
|
+
closest_items = items_to_consider.select{ |item|
|
|
1067
|
+
item[4][:duration_from_and_to_depot][@centroids.length] <= item[4][:duration_from_and_to_depot].sort[margin]
|
|
1068
|
+
}
|
|
1069
|
+
break unless closest_items.empty?
|
|
1070
|
+
}
|
|
1071
|
+
closest_items
|
|
1072
|
+
end
|
|
1073
|
+
|
|
1074
|
+
def output_cluster_geojson
|
|
1075
|
+
return unless @geojson_dump_folder
|
|
1076
|
+
|
|
1077
|
+
@balance_coeff ||= Array.new(@number_of_clusters, 1.0)
|
|
1078
|
+
|
|
1079
|
+
@start_time ||= Time.now.strftime('%H:%M:%S').parameterize
|
|
1080
|
+
@geojson_colors ||= Array.new(@number_of_clusters) { |i| GEOJSON_PALETTE[i % GEOJSON_PALETTE.size] }
|
|
1081
|
+
|
|
1082
|
+
polygons = []
|
|
1083
|
+
points = []
|
|
1084
|
+
@clusters.each_with_index{ |cluster, c_index|
|
|
1085
|
+
polygons << collect_hulls(cluster, c_index)
|
|
1086
|
+
points << collect_points(cluster, c_index)
|
|
1087
|
+
}
|
|
1088
|
+
polygons.flatten!.compact!
|
|
1089
|
+
points.flatten!.compact!
|
|
1090
|
+
file_name = "generated_cluster_#{@start_time}_iteration_#{@iteration}".parameterize
|
|
1091
|
+
geojson = {
|
|
1092
|
+
type: 'FeatureCollection',
|
|
1093
|
+
features: polygons + points
|
|
1094
|
+
}
|
|
1095
|
+
|
|
1096
|
+
File.write(File.join(@geojson_dump_folder, "#{file_name}.geojson"), geojson.to_json)
|
|
1097
|
+
puts 'Clusters saved : ' + file_name
|
|
1098
|
+
end
|
|
1099
|
+
|
|
1100
|
+
def collect_hulls(cluster, c_index)
|
|
1101
|
+
return [] if cluster.data_items.empty?
|
|
1102
|
+
|
|
1103
|
+
color = @geojson_colors[c_index]
|
|
1104
|
+
|
|
1105
|
+
vector = cluster.data_items.collect{ |item|
|
|
1106
|
+
[item[1], item[0]]
|
|
1107
|
+
}
|
|
1108
|
+
hull = Hull.get_hull(vector)
|
|
1109
|
+
return nil if hull.nil?
|
|
1110
|
+
|
|
1111
|
+
totals = Hash.new(0)
|
|
1112
|
+
|
|
1113
|
+
total_keys = cluster.data_items[0][3].keys - [:duration_from_and_to_depot, :matrix_index]
|
|
1114
|
+
cluster.data_items.each{ |d|
|
|
1115
|
+
total_keys.each{ |key| totals["total_#{key}"] += d[3][key].to_f if d[3] && d[3][key] && d[3][key].is_a?(Numeric) }
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1118
|
+
features = [
|
|
1119
|
+
{
|
|
1120
|
+
type: 'Feature',
|
|
1121
|
+
properties: {
|
|
1122
|
+
color: color,
|
|
1123
|
+
'marker-size': 'large',
|
|
1124
|
+
'marker-color': color,
|
|
1125
|
+
stroke: '#000000',
|
|
1126
|
+
'stroke-opacity': 0,
|
|
1127
|
+
'stroke-width': 10,
|
|
1128
|
+
name: "#{@centroids[c_index][4][:id]&.join(',')}_center",
|
|
1129
|
+
lat_lon: @centroids[c_index][0..1].join(','),
|
|
1130
|
+
lon_lat: @centroids[c_index][0..1].reverse.join(','),
|
|
1131
|
+
matrix_index: @centroids[c_index][3][:matrix_index],
|
|
1132
|
+
point_count: cluster.data_items.size,
|
|
1133
|
+
depot_distance: @centroids[c_index][3][:duration_from_and_to_depot],
|
|
1134
|
+
balance_coeff: @balance_coeff[c_index],
|
|
1135
|
+
}.merge(totals),
|
|
1136
|
+
geometry: {
|
|
1137
|
+
type: 'Point',
|
|
1138
|
+
coordinates: [@centroids[c_index][1], @centroids[c_index][0]]
|
|
1139
|
+
}
|
|
1140
|
+
},
|
|
1141
|
+
{
|
|
1142
|
+
type: 'Feature',
|
|
1143
|
+
properties: {
|
|
1144
|
+
color: color,
|
|
1145
|
+
fill: color,
|
|
1146
|
+
name: @centroids[c_index][4][:id]&.join(','),
|
|
1147
|
+
lat_lon: @centroids[c_index][0..1].join(','),
|
|
1148
|
+
lon_lat: @centroids[c_index][0..1].reverse.join(','),
|
|
1149
|
+
matrix_index: @centroids[c_index][3][:matrix_index],
|
|
1150
|
+
point_count: cluster.data_items.size,
|
|
1151
|
+
depot_distance: @centroids[c_index][3][:duration_from_and_to_depot],
|
|
1152
|
+
balance_coeff: @balance_coeff[c_index],
|
|
1153
|
+
}.merge(totals),
|
|
1154
|
+
geometry: {
|
|
1155
|
+
type: 'Polygon',
|
|
1156
|
+
coordinates: [hull + [hull.first]]
|
|
1157
|
+
}
|
|
1158
|
+
}
|
|
1159
|
+
]
|
|
1160
|
+
|
|
1161
|
+
features
|
|
1162
|
+
end
|
|
1163
|
+
|
|
1164
|
+
def collect_points(cluster, c_index)
|
|
1165
|
+
color = @geojson_colors[c_index]
|
|
1166
|
+
cluster.data_items.collect{ |item|
|
|
1167
|
+
{
|
|
1168
|
+
type: 'Feature',
|
|
1169
|
+
properties: {
|
|
1170
|
+
color: color,
|
|
1171
|
+
'marker-size': 'small',
|
|
1172
|
+
'marker-color': color,
|
|
1173
|
+
name: item[2],
|
|
1174
|
+
lat_lon: item[0..1].join(','),
|
|
1175
|
+
lon_lat: item[0..1].reverse.join(','),
|
|
1176
|
+
distance: @distance_function.call(item, @centroids[c_index]),
|
|
1177
|
+
distance_balanced: distance(item, @centroids[c_index], c_index),
|
|
1178
|
+
}.merge(item[3]).merge(item[4].reject{ |k| k == :linked_item }),
|
|
1179
|
+
geometry: {
|
|
1180
|
+
type: 'Point',
|
|
1181
|
+
coordinates: [item[1], item[0]]
|
|
1182
|
+
}
|
|
1183
|
+
}
|
|
1184
|
+
}
|
|
1185
|
+
end
|
|
1186
|
+
end
|
|
1187
|
+
end
|
|
1188
|
+
end
|