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,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