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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: c9295e785852bb79697a072224baa660c51ddd34e26b0d9aab44b86a9dcf5914
4
+ data.tar.gz: 52a0f93de2790c9d1a72930e7bf9b08a6990564b54276bc34ec888a13364ea79
5
+ SHA512:
6
+ metadata.gz: d918f9ce687c8bc7693847df479961069f1687f76c86802485a6c741510125a5d6469df29dffca991a2b0ddb30617d4ed2c400c6495f7eca8a3f816c9e6675a3
7
+ data.tar.gz: c4eb9ca4b0b4df36736d221d7f879f1adb9e96d349c3f5ff6a87c9ad9cae0b4b2cee96b796258fc0f7e93c154602e0e88361703726e9903b7c154dabc4651f55
@@ -0,0 +1,152 @@
1
+ # Copyright © Cartoway
2
+ #
3
+ # Minimal local replacements for the former ai4r dependency.
4
+ # Kept under the Ai4r:: namespace for public API and Marshal fixture compatibility.
5
+ #
6
+ # frozen_string_literal: true
7
+
8
+ module Ai4r
9
+ module Data
10
+ # Declares configurable parameters (attr_accessor + docs hash).
11
+ module Parameterizable
12
+ module ClassMethods
13
+ def get_parameters_info
14
+ @_params_info_ || {}
15
+ end
16
+
17
+ def parameters_info(params_info)
18
+ @_params_info_ = get_parameters_info.merge(params_info)
19
+ params_info.each_key do |param|
20
+ attr_accessor param unless method_defined?(param) || method_defined?("#{param}=")
21
+ end
22
+ end
23
+ end
24
+
25
+ def set_parameters(params)
26
+ params.each do |key, val|
27
+ public_send("#{key}=", val) if respond_to?("#{key}=")
28
+ end
29
+ self
30
+ end
31
+
32
+ def get_parameters
33
+ params = {}
34
+ self.class.get_parameters_info.each_key do |key|
35
+ params[key] = send(key) if respond_to?(key)
36
+ end
37
+ params
38
+ end
39
+
40
+ def self.included(base)
41
+ base.extend(ClassMethods)
42
+ end
43
+ end
44
+
45
+ # Collection of data items used as clustering input / cluster containers.
46
+ # Instance variable names must stay stable for Marshal.load of fixtures.
47
+ class DataSet
48
+ attr_reader :data_labels, :data_items
49
+
50
+ def initialize(options = {})
51
+ @data_labels = []
52
+ @data_items = options[:data_items] || []
53
+ set_data_labels(options[:data_labels]) if options[:data_labels]
54
+ set_data_items(options[:data_items]) if options[:data_items]
55
+ end
56
+
57
+ def set_data_labels(labels)
58
+ check_data_labels(labels)
59
+ @data_labels = labels
60
+ self
61
+ end
62
+
63
+ def set_data_items(items)
64
+ check_data_items(items)
65
+ @data_labels = default_data_labels(items) if @data_labels.empty?
66
+ @data_items = items
67
+ self
68
+ end
69
+
70
+ def num_attributes
71
+ @data_items.empty? ? 0 : @data_items.first.size
72
+ end
73
+
74
+ def <<(data_item)
75
+ if data_item.nil? || !data_item.is_a?(Enumerable) || data_item.empty?
76
+ raise ArgumentError, 'Data must not be an non empty array.'
77
+ elsif @data_items.empty?
78
+ set_data_items([data_item])
79
+ elsif data_item.length != num_attributes
80
+ raise ArgumentError, 'Number of attributes do not match. ' \
81
+ "#{data_item.length} attributes provided, " \
82
+ "#{num_attributes} attributes expected."
83
+ else
84
+ @data_items << data_item
85
+ end
86
+ end
87
+
88
+ protected
89
+
90
+ def check_data_items(data_items)
91
+ if !data_items || data_items.empty?
92
+ raise ArgumentError, 'Examples data set must not be empty.'
93
+ elsif !data_items.first.is_a?(Enumerable)
94
+ raise ArgumentError, 'Unkown format for example data.'
95
+ end
96
+
97
+ attributes_num = data_items.first.length
98
+ data_items.each_index do |index|
99
+ next if data_items[index].length == attributes_num
100
+
101
+ raise ArgumentError,
102
+ 'Quantity of attributes is inconsistent. ' \
103
+ "The first item has #{attributes_num} attributes " \
104
+ "and row #{index} has #{data_items[index].length} attributes"
105
+ end
106
+ end
107
+
108
+ def check_data_labels(labels)
109
+ return if @data_items.empty?
110
+ return if labels.length == @data_items.first.length
111
+
112
+ raise ArgumentError,
113
+ 'Number of labels and attributes do not match. ' \
114
+ "#{labels.length} labels and " \
115
+ "#{@data_items.first.length} attributes found."
116
+ end
117
+
118
+ def default_data_labels(data_items)
119
+ data_labels = []
120
+ data_items[0][0..-2].each_index do |i|
121
+ data_labels[i] = "attribute_#{i + 1}"
122
+ end
123
+ data_labels[data_labels.length] = 'class_value'
124
+ data_labels
125
+ end
126
+ end
127
+ end
128
+
129
+ module Clusterers
130
+ # Thin base replacing ai4r's Clusterer/KMeans scaffolding.
131
+ class Clusterer
132
+ include Ai4r::Data::Parameterizable
133
+
134
+ attr_reader :data_set, :number_of_clusters, :clusters, :centroids
135
+
136
+ protected
137
+
138
+ def get_min_index(array)
139
+ min = array.first
140
+ index = 0
141
+ array.each_index do |i|
142
+ x = array[i]
143
+ if x < min
144
+ min = x
145
+ index = i
146
+ end
147
+ end
148
+ index
149
+ end
150
+ end
151
+ end
152
+ end
@@ -0,0 +1,234 @@
1
+ # Copyright © Cartoway
2
+ #
3
+ # This file is part of balanced_vrp_clustering.
4
+ #
5
+ # Cartoway balanced_vrp_clustering is free software. You can redistribute it and/or
6
+ # modify since you respect the terms of the GNU Affero General
7
+ # Public License as published by the Free Software Foundation,
8
+ # either version 3 of the License, or (at your option) any later version.
9
+ #
10
+ # Cartoway Planner is distributed in the hope that it will be useful, but WITHOUT
11
+ # ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
12
+ # or FITNESS FOR A PARTICULAR PURPOSE. See the Licenses for more details.
13
+ #
14
+ # You should have received a copy of the GNU Affero General Public License
15
+ # along with Cartoway Planner. If not, see:
16
+ # <http://www.gnu.org/licenses/agpl.html>
17
+ #
18
+
19
+ # frozen_string_literal: true
20
+
21
+ require 'json'
22
+
23
+ module Ai4r
24
+ module Clusterers
25
+ # Runs clustering via the Rust native extension and populates Ruby cluster state.
26
+ module BalancedVRPClusteringRustEngine
27
+ module_function
28
+
29
+ def available?
30
+ return @available if defined?(@available)
31
+
32
+ require 'balanced_vrp_clustering_native'
33
+ @available = BalancedVRPClusteringNative.available?
34
+ rescue LoadError
35
+ @available = false
36
+ end
37
+
38
+ def build(clusterer, data_set, cut_symbol, related_item_indices, cut_ratio, options)
39
+ payload = InputSerializer.new(
40
+ clusterer: clusterer,
41
+ data_set: data_set,
42
+ cut_symbol: cut_symbol,
43
+ related_item_indices: clusterer.instance_variable_get(:@related_item_indices_for_rust) || related_item_indices,
44
+ cut_ratio: cut_ratio,
45
+ options: options
46
+ ).to_h
47
+
48
+ json_out = BalancedVRPClusteringNative.build_from_json(JSON.generate(payload))
49
+ result = JSON.parse(json_out)
50
+
51
+ apply_result(clusterer, data_set, result)
52
+ clusterer
53
+ end
54
+
55
+ def apply_result(clusterer, data_set, result)
56
+ clusterer.instance_variable_set(:@iteration, result['iterations'])
57
+ items_by_id = data_set.data_items.index_by { |item| item[2].to_s }
58
+
59
+ clusters = result['clusters'].map do |item_ids|
60
+ ds = Ai4r::Data::DataSet.new(data_labels: data_set.data_labels)
61
+ item_ids.each do |item_id|
62
+ item = items_by_id[item_id.to_s]
63
+ raise KeyError, "unknown item id #{item_id.inspect} in Rust output" unless item
64
+
65
+ ds << item
66
+ end
67
+ ds
68
+ end
69
+ clusterer.instance_variable_set(:@clusters, clusters)
70
+ centroids = centroids_from_clusters(clusters, clusterer.vehicles)
71
+ sync_centroid_state!(centroids, clusters, clusterer.vehicles)
72
+ clusterer.instance_variable_set(:@centroids, centroids)
73
+ clusterer.instance_variable_set(:@number_of_clusters, clusters.size)
74
+ clusterer.instance_variable_set(:@balance_coeff, Array.new(clusters.size, 1.0))
75
+ end
76
+
77
+ # Mirror Ruby post-build centroid fields used by output_cluster_stats / geojson.
78
+ def sync_centroid_state!(centroids, clusters, vehicles)
79
+ centroids.each_with_index do |centroid, index|
80
+ vehicle = vehicles[index] || {}
81
+ cluster = clusters[index]
82
+
83
+ load = Hash.new(0.0)
84
+ visit_count = 0.0
85
+ cluster.data_items.each do |item|
86
+ item[3].each { |unit, qty| load[unit] += qty.to_f if qty }
87
+ visit_count += (item[3][:visits] || 1).to_f
88
+ end
89
+ centroid[3] = load
90
+ load[:duration] = load[:duration].to_f
91
+
92
+ depot_durations = cluster.data_items.filter_map do |item|
93
+ arr = item[4][:duration_from_and_to_depot]
94
+ next unless arr.is_a?(Array) && index < arr.size
95
+
96
+ arr[index].to_f
97
+ end
98
+
99
+ centroid[4][:visit_count] = visit_count
100
+ centroid[4][:route_time] = centroid[4][:route_time].to_f
101
+ centroid[4][:vehicle_count] = vehicle[:vehicle_count] || 1
102
+ centroid[4][:total_work_days] = vehicle[:total_work_days] || 1
103
+ centroid[4][:capacities] = vehicle[:capacities] || {}
104
+ centroid[4][:skills] = vehicle[:skills] || []
105
+ centroid[4][:duration_from_and_to_depot] = depot_durations.empty? ? 0.0 : depot_durations.sum / depot_durations.size
106
+ end
107
+ end
108
+
109
+ def centroids_from_clusters(clusters, vehicles)
110
+ clusters.each_with_index.map do |cluster, index|
111
+ vehicle = vehicles[index] || {}
112
+ depot = vehicle[:depot] || {}
113
+ depot_coords = depot[:coordinates] || [0.0, 0.0]
114
+ items = cluster.data_items
115
+
116
+ lat, lon, representative_id =
117
+ if items.empty?
118
+ [depot_coords[0], depot_coords[1], Array(vehicle[:id]).flatten.first]
119
+ else
120
+ total_visits = items.sum { |item| (item[3][:visits] || 1).to_f }
121
+ total_visits = 1.0 if total_visits.zero?
122
+ mean_lat = items.sum { |item| item[0].to_f * (item[3][:visits] || 1).to_f } / total_visits
123
+ mean_lon = items.sum { |item| item[1].to_f * (item[3][:visits] || 1).to_f } / total_visits
124
+ representative = items.min_by do |item|
125
+ Helper.flying_distance([item[0], item[1]], [mean_lat, mean_lon])
126
+ end
127
+ [representative[0], representative[1], representative[2]]
128
+ end
129
+
130
+ skills = vehicle.merge(
131
+ matrix_index: depot[:matrix_index],
132
+ duration_from_and_to_depot: depot[:duration_from_and_to_depot],
133
+ route_time: 0,
134
+ capacity_offence_coeff: 0,
135
+ visit_count: 0
136
+ )
137
+ [lat, lon, representative_id, Hash.new(0), skills]
138
+ end
139
+ end
140
+
141
+ # JSON payload shared with benchmark fixtures and the Rust CLI.
142
+ class InputSerializer
143
+ def initialize(clusterer:, data_set:, cut_symbol:, related_item_indices:, cut_ratio:, options:)
144
+ @clusterer = clusterer
145
+ @data_set = data_set
146
+ @cut_symbol = cut_symbol
147
+ @related_item_indices = related_item_indices
148
+ @cut_ratio = cut_ratio
149
+ @options = options
150
+ end
151
+
152
+ def to_h
153
+ {
154
+ 'seed' => self.class.seed_to_u64(@options[:seed]),
155
+ 'cut_symbol' => @cut_symbol&.to_s,
156
+ 'cut_ratio' => @cut_ratio.to_f,
157
+ 'max_iterations' => @clusterer.max_iterations.to_i,
158
+ 'vehicles' => @clusterer.vehicles.map { |v| self.class.vehicle_to_json(v) },
159
+ 'items' => @data_set.data_items.map { |i| self.class.item_to_json(i) },
160
+ 'distance_matrix' => self.class.json_distance_matrix(@clusterer.distance_matrix),
161
+ 'centroid_indices' => @clusterer.centroid_indices || [],
162
+ 'related_item_indices' => serialize_related_indices(@related_item_indices)
163
+ }
164
+ end
165
+
166
+ def self.seed_to_u64(seed)
167
+ seed.to_i & ((1 << 64) - 1)
168
+ end
169
+
170
+ def self.json_float(value, default: 0.0)
171
+ value.nil? ? default : value.to_f
172
+ end
173
+
174
+ def self.json_float_array(values)
175
+ Array(values).map { |v| json_float(v) }
176
+ end
177
+
178
+ def self.json_coordinates(coords)
179
+ c = coords || [0.0, 0.0]
180
+ [json_float(c[0]), json_float(c[1])]
181
+ end
182
+
183
+ def self.json_distance_matrix(matrix)
184
+ return nil unless matrix
185
+
186
+ matrix.map { |row| row.map { |cell| json_float(cell) } }
187
+ end
188
+
189
+ def self.json_string_array(values)
190
+ Array(values).flatten.map(&:to_s)
191
+ end
192
+
193
+ def self.item_to_json(item)
194
+ {
195
+ 'id' => item[2].to_s,
196
+ 'lat' => json_float(item[0]),
197
+ 'lon' => json_float(item[1]),
198
+ 'quantities' => item[3].transform_keys(&:to_s).transform_values { |v| json_float(v) },
199
+ 'v_id' => json_string_array(item[4][:v_id]),
200
+ 'skills' => json_string_array(item[4][:skills]),
201
+ 'day_skills' => json_string_array(item[4][:day_skills]),
202
+ 'matrix_index' => item[4][:matrix_index],
203
+ 'duration_from_and_to_depot' => json_float_array(item[4][:duration_from_and_to_depot])
204
+ }
205
+ end
206
+
207
+ def self.vehicle_to_json(vehicle)
208
+ depot = vehicle[:depot] || {}
209
+ {
210
+ 'id' => json_string_array(vehicle[:id]),
211
+ 'depot' => {
212
+ 'coordinates' => json_coordinates(depot[:coordinates]),
213
+ 'matrix_index' => depot[:matrix_index]
214
+ }.compact,
215
+ 'capacities' => (vehicle[:capacities] || {}).transform_keys(&:to_s).transform_values { |v| json_float(v) },
216
+ 'skills' => json_string_array(vehicle[:skills]),
217
+ 'day_skills' => json_string_array(vehicle[:day_skills]),
218
+ 'duration' => json_float(vehicle[:duration]),
219
+ 'total_work_days' => json_float(vehicle[:total_work_days], default: 1.0),
220
+ 'vehicle_count' => json_float(vehicle[:vehicle_count], default: 1.0)
221
+ }
222
+ end
223
+
224
+ private
225
+
226
+ def serialize_related_indices(related)
227
+ related.transform_keys(&:to_s).transform_values do |groups|
228
+ groups.map { |g| g.map(&:to_i) }
229
+ end
230
+ end
231
+ end
232
+ end
233
+ end
234
+ end