blackopsrepl commited on
Commit
531908f
·
1 Parent(s): 576f373

fix: start visits unassigned

Browse files

Remove the greedy route seeding pass from Bergamo demo data so every generated technician route starts with an empty visits list. The demo payload now represents the real planning starting point: work exists as service visits, but no technician owns it until the solver assigns it.

Add an assigned_visits hard constraint that requires every service visit to appear exactly once in the route lists. This keeps unassigned demo data from becoming an acceptable final solution and also treats duplicate or invalid visit indexes as hard issues.

Update the managed SolverForge constraint metadata and generated UI model to include the new hard constraint, while keeping route_metrics as an internal helper module only. The map sidebar now renders an Unassigned visits table so the empty initial route state is visible in the stock SolverForge UI workspace.

solverforge.app.toml CHANGED
@@ -50,6 +50,11 @@ elements = "service_visits"
50
  allows_unassigned = false
51
  enabled = true
52
 
 
 
 
 
 
53
  [[constraints]]
54
  name = "balance_workload"
55
  module = "balance_workload"
 
50
  allows_unassigned = false
51
  enabled = true
52
 
53
+ [[constraints]]
54
+ name = "assigned_visits"
55
+ module = "assigned_visits"
56
+ enabled = true
57
+
58
  [[constraints]]
59
  name = "balance_workload"
60
  module = "balance_workload"
src/constraints/assigned_visits.rs ADDED
@@ -0,0 +1,168 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ use crate::domain::FieldServicePlan;
2
+ use solverforge::prelude::*;
3
+ use solverforge::IncrementalConstraint;
4
+
5
+ /// HARD: every service visit must appear exactly once in a technician route.
6
+ pub fn constraint() -> impl IncrementalConstraint<FieldServicePlan, HardSoftScore> {
7
+ AssignedVisitsConstraint
8
+ }
9
+
10
+ struct AssignedVisitsConstraint;
11
+
12
+ #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
13
+ struct AssignmentIssues {
14
+ unassigned: i64,
15
+ duplicate_assignments: i64,
16
+ invalid_assignments: i64,
17
+ }
18
+
19
+ impl AssignmentIssues {
20
+ fn total(self) -> i64 {
21
+ self.unassigned + self.duplicate_assignments + self.invalid_assignments
22
+ }
23
+ }
24
+
25
+ impl IncrementalConstraint<FieldServicePlan, HardSoftScore> for AssignedVisitsConstraint {
26
+ fn evaluate(&self, solution: &FieldServicePlan) -> HardSoftScore {
27
+ HardSoftScore::of(-assignment_issues(solution).total(), 0)
28
+ }
29
+
30
+ fn match_count(&self, solution: &FieldServicePlan) -> usize {
31
+ assignment_issues(solution).total() as usize
32
+ }
33
+
34
+ fn initialize(&mut self, solution: &FieldServicePlan) -> HardSoftScore {
35
+ self.evaluate(solution)
36
+ }
37
+
38
+ fn on_insert(
39
+ &mut self,
40
+ solution: &FieldServicePlan,
41
+ _entity_index: usize,
42
+ _descriptor_index: usize,
43
+ ) -> HardSoftScore {
44
+ self.evaluate(solution)
45
+ }
46
+
47
+ fn on_retract(
48
+ &mut self,
49
+ solution: &FieldServicePlan,
50
+ _entity_index: usize,
51
+ _descriptor_index: usize,
52
+ ) -> HardSoftScore {
53
+ -self.evaluate(solution)
54
+ }
55
+
56
+ fn reset(&mut self) {}
57
+
58
+ fn name(&self) -> &str {
59
+ "Assigned Visits"
60
+ }
61
+
62
+ fn is_hard(&self) -> bool {
63
+ true
64
+ }
65
+
66
+ fn weight(&self) -> HardSoftScore {
67
+ HardSoftScore::of(1, 0)
68
+ }
69
+ }
70
+
71
+ fn assignment_issues(plan: &FieldServicePlan) -> AssignmentIssues {
72
+ let mut counts = vec![0usize; plan.service_visits.len()];
73
+ let mut issues = AssignmentIssues::default();
74
+
75
+ for route in &plan.technician_routes {
76
+ for &visit_idx in &route.visits {
77
+ if let Some(count) = counts.get_mut(visit_idx) {
78
+ *count += 1;
79
+ } else {
80
+ issues.invalid_assignments += 1;
81
+ }
82
+ }
83
+ }
84
+
85
+ for count in counts {
86
+ match count {
87
+ 0 => issues.unassigned += 1,
88
+ 1 => {}
89
+ extra => issues.duplicate_assignments += (extra - 1) as i64,
90
+ }
91
+ }
92
+
93
+ issues
94
+ }
95
+
96
+ #[cfg(test)]
97
+ mod tests {
98
+ use super::*;
99
+ use crate::domain::{
100
+ FieldServicePlan, ServiceVisit, ServiceVisitInit, TechnicianRoute, TechnicianRouteInit,
101
+ };
102
+ use solverforge::IncrementalConstraint;
103
+
104
+ #[test]
105
+ fn empty_routes_are_penalized_for_unassigned_visits() {
106
+ let score = constraint().evaluate(&sample_plan(vec![vec![]]));
107
+
108
+ assert_eq!(score, HardSoftScore::of(-2, 0));
109
+ }
110
+
111
+ #[test]
112
+ fn every_visit_once_is_feasible() {
113
+ let score = constraint().evaluate(&sample_plan(vec![vec![0, 1]]));
114
+
115
+ assert_eq!(score, HardSoftScore::ZERO);
116
+ }
117
+
118
+ #[test]
119
+ fn duplicate_or_invalid_visit_indexes_are_hard_issues() {
120
+ let score = constraint().evaluate(&sample_plan(vec![vec![0, 0, 99]]));
121
+
122
+ assert_eq!(score, HardSoftScore::of(-3, 0));
123
+ }
124
+
125
+ fn sample_plan(route_visits: Vec<Vec<usize>>) -> FieldServicePlan {
126
+ let service_visits = (0..2)
127
+ .map(|idx| {
128
+ ServiceVisit::new(ServiceVisitInit {
129
+ id: format!("visit-{idx}"),
130
+ name: format!("Visit {idx}"),
131
+ customer: format!("Customer {idx}"),
132
+ location_idx: idx,
133
+ duration_minutes: 30,
134
+ earliest_minute: 480,
135
+ latest_minute: 1020,
136
+ required_skill_mask: 0,
137
+ required_parts_mask: 0,
138
+ priority: 1,
139
+ territory: "center".to_string(),
140
+ })
141
+ })
142
+ .collect();
143
+ let technician_routes = route_visits
144
+ .into_iter()
145
+ .enumerate()
146
+ .map(|(idx, visits)| {
147
+ let mut route = TechnicianRoute::new(TechnicianRouteInit {
148
+ id: format!("route-{idx}"),
149
+ technician_id: format!("tech-{idx}"),
150
+ technician_name: format!("Tech {idx}"),
151
+ color: "#2563eb".to_string(),
152
+ start_location_idx: 0,
153
+ end_location_idx: 0,
154
+ shift_start_minute: 480,
155
+ shift_end_minute: 1020,
156
+ max_route_minutes: 480,
157
+ skill_mask: 0,
158
+ inventory_mask: 0,
159
+ territory: "center".to_string(),
160
+ });
161
+ route.visits = visits;
162
+ route
163
+ })
164
+ .collect();
165
+
166
+ FieldServicePlan::new(Vec::new(), service_visits, Vec::new(), technician_routes)
167
+ }
168
+ }
src/constraints/mod.rs CHANGED
@@ -11,6 +11,7 @@ pub use self::assemble::create_constraints;
11
  pub mod route_metrics;
12
 
13
  // @solverforge:begin constraint-modules
 
14
  mod balance_workload;
15
  mod minimize_travel;
16
  mod priority_slack;
@@ -28,6 +29,7 @@ mod assemble {
28
  pub fn create_constraints() -> impl ConstraintSet<FieldServicePlan, HardSoftScore> {
29
  // @solverforge:begin constraint-calls
30
  (
 
31
  balance_workload::constraint(),
32
  minimize_travel::constraint(),
33
  priority_slack::constraint(),
 
11
  pub mod route_metrics;
12
 
13
  // @solverforge:begin constraint-modules
14
+ mod assigned_visits;
15
  mod balance_workload;
16
  mod minimize_travel;
17
  mod priority_slack;
 
29
  pub fn create_constraints() -> impl ConstraintSet<FieldServicePlan, HardSoftScore> {
30
  // @solverforge:begin constraint-calls
31
  (
32
+ assigned_visits::constraint(),
33
  balance_workload::constraint(),
34
  minimize_travel::constraint(),
35
  priority_slack::constraint(),
src/data/data_seed.rs CHANGED
@@ -7,7 +7,6 @@ use solverforge_maps::{
7
  encode_polyline, BoundingBox, Coord, NetworkConfig, RoadNetwork, RoutingError, UNREACHABLE,
8
  };
9
 
10
- use crate::constraints::route_metrics::{route_stats, RouteStats};
11
  use crate::domain::{
12
  FieldServicePlan, Location, ServiceVisit, ServiceVisitInit, TechnicianRoute,
13
  TechnicianRouteInit, TravelLeg, TravelLegInit,
@@ -116,13 +115,7 @@ pub async fn generate(demo: DemoData) -> Result<FieldServicePlan, DemoDataError>
116
  let matrix = network.compute_matrix(&coords, None).await;
117
  let travel_legs = build_travel_legs(&network, &matrix, &coords);
118
  let service_visits = build_service_visits(demo);
119
- let mut technician_routes = build_technician_routes(demo);
120
- seed_initial_routes(
121
- &locations,
122
- &service_visits,
123
- &travel_legs,
124
- &mut technician_routes,
125
- );
126
 
127
  Ok(FieldServicePlan::new(
128
  locations,
@@ -202,91 +195,6 @@ fn build_technician_routes(demo: DemoData) -> Vec<TechnicianRoute> {
202
  .collect()
203
  }
204
 
205
- fn seed_initial_routes(
206
- locations: &[Location],
207
- service_visits: &[ServiceVisit],
208
- travel_legs: &[TravelLeg],
209
- technician_routes: &mut [TechnicianRoute],
210
- ) {
211
- let mut order = (0..service_visits.len()).collect::<Vec<_>>();
212
- order.sort_by_key(|&idx| {
213
- let visit = &service_visits[idx];
214
- (visit.latest_minute, -visit.priority)
215
- });
216
-
217
- for visit_idx in order {
218
- if let Some((route_idx, insert_pos)) = best_insertion(
219
- locations,
220
- service_visits,
221
- travel_legs,
222
- technician_routes,
223
- visit_idx,
224
- ) {
225
- technician_routes[route_idx]
226
- .visits
227
- .insert(insert_pos, visit_idx);
228
- }
229
- }
230
- }
231
-
232
- fn best_insertion(
233
- locations: &[Location],
234
- service_visits: &[ServiceVisit],
235
- travel_legs: &[TravelLeg],
236
- technician_routes: &[TechnicianRoute],
237
- visit_idx: usize,
238
- ) -> Option<(usize, usize)> {
239
- let plan = FieldServicePlan::new(
240
- locations.to_vec(),
241
- service_visits.to_vec(),
242
- travel_legs.to_vec(),
243
- technician_routes.to_vec(),
244
- );
245
- let mut best: Option<(InsertionCost, usize, usize)> = None;
246
-
247
- for (route_idx, route) in technician_routes.iter().enumerate() {
248
- for insert_pos in 0..=route.visits.len() {
249
- let mut candidate = route.clone();
250
- candidate.visits.insert(insert_pos, visit_idx);
251
- let cost = insertion_cost(&plan, &candidate);
252
- if best
253
- .as_ref()
254
- .map(|(best_cost, _, _)| cost < *best_cost)
255
- .unwrap_or(true)
256
- {
257
- best = Some((cost, route_idx, insert_pos));
258
- }
259
- }
260
- }
261
-
262
- best.map(|(_, route_idx, insert_pos)| (route_idx, insert_pos))
263
- }
264
-
265
- #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
266
- struct InsertionCost {
267
- hard: i64,
268
- soft: i64,
269
- }
270
-
271
- fn insertion_cost(plan: &FieldServicePlan, route: &TechnicianRoute) -> InsertionCost {
272
- let stats = route_stats(plan, route);
273
- InsertionCost {
274
- hard: hard_issue_count(&stats),
275
- soft: stats.travel_minutes() + stats.route_minutes
276
- - (stats.territory_matches * 10)
277
- - stats.priority_slack,
278
- }
279
- }
280
-
281
- fn hard_issue_count(stats: &RouteStats) -> i64 {
282
- stats.invalid_visits
283
- + stats.unreachable_legs
284
- + stats.missing_skill_visits
285
- + stats.missing_part_visits
286
- + stats.late_minutes
287
- + stats.overtime_minutes
288
- }
289
-
290
  fn build_travel_legs(
291
  network: &RoadNetwork,
292
  matrix: &solverforge_maps::TravelTimeMatrix,
@@ -703,3 +611,18 @@ const TECHNICIANS: &[TechnicianSeed] = &[
703
  territory: "east",
704
  },
705
  ];
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7
  encode_polyline, BoundingBox, Coord, NetworkConfig, RoadNetwork, RoutingError, UNREACHABLE,
8
  };
9
 
 
10
  use crate::domain::{
11
  FieldServicePlan, Location, ServiceVisit, ServiceVisitInit, TechnicianRoute,
12
  TechnicianRouteInit, TravelLeg, TravelLegInit,
 
115
  let matrix = network.compute_matrix(&coords, None).await;
116
  let travel_legs = build_travel_legs(&network, &matrix, &coords);
117
  let service_visits = build_service_visits(demo);
118
+ let technician_routes = build_technician_routes(demo);
 
 
 
 
 
 
119
 
120
  Ok(FieldServicePlan::new(
121
  locations,
 
195
  .collect()
196
  }
197
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
198
  fn build_travel_legs(
199
  network: &RoadNetwork,
200
  matrix: &solverforge_maps::TravelTimeMatrix,
 
611
  territory: "east",
612
  },
613
  ];
614
+
615
+ #[cfg(test)]
616
+ mod tests {
617
+ use super::*;
618
+
619
+ #[test]
620
+ fn generated_technician_routes_start_without_assigned_visits() {
621
+ for demo in DemoData::available_demo_data() {
622
+ let routes = build_technician_routes(*demo);
623
+
624
+ assert!(!routes.is_empty());
625
+ assert!(routes.iter().all(|route| route.visits.is_empty()));
626
+ }
627
+ }
628
+ }
static/app.js CHANGED
@@ -387,6 +387,7 @@
387
 
388
  function renderRouteCards(plan) {
389
  routeCards.innerHTML = '';
 
390
  (plan.technician_routes || []).forEach(function (route) {
391
  var stats = routeStats(plan, route);
392
  var card = SF.el('div', {
@@ -418,6 +419,35 @@
418
  });
419
  }
420
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
421
  function renderTimeline(plan) {
422
  var timelineConfig = buildTimelineConfig(plan);
423
  timelineContainer.innerHTML = '';
 
387
 
388
  function renderRouteCards(plan) {
389
  routeCards.innerHTML = '';
390
+ renderUnassignedCard(plan);
391
  (plan.technician_routes || []).forEach(function (route) {
392
  var stats = routeStats(plan, route);
393
  var card = SF.el('div', {
 
419
  });
420
  }
421
 
422
+ function renderUnassignedCard(plan) {
423
+ var assigned = assignedVisitSet(plan.technician_routes || []);
424
+ var rows = (plan.service_visits || []).reduce(function (items, visit, idx) {
425
+ if (assigned[idx]) return items;
426
+ items.push([
427
+ visit.customer || visit.name || visit.id,
428
+ timeLabel(visit.earliest_minute) + '-' + timeLabel(visit.latest_minute),
429
+ formatDuration(visit.duration_minutes || 0),
430
+ ]);
431
+ return items;
432
+ }, []);
433
+ if (!rows.length) return;
434
+
435
+ var card = SF.el('div', {
436
+ className: 'sf-section',
437
+ style: {
438
+ borderLeft: '4px solid #64748b',
439
+ padding: '12px',
440
+ borderRadius: '8px',
441
+ },
442
+ });
443
+ card.appendChild(SF.el('h3', { style: { margin: '0 0 8px' } }, 'Unassigned visits'));
444
+ card.appendChild(SF.createTable({
445
+ columns: ['Visit', 'Window', 'Duration'],
446
+ rows: rows,
447
+ }));
448
+ routeCards.appendChild(card);
449
+ }
450
+
451
  function renderTimeline(plan) {
452
  var timelineConfig = buildTimelineConfig(plan);
453
  timelineContainer.innerHTML = '';
static/generated/ui-model.json CHANGED
@@ -1,5 +1,6 @@
1
  {
2
  "constraints": [
 
3
  "balance_workload",
4
  "minimize_travel",
5
  "priority_slack",
 
1
  {
2
  "constraints": [
3
+ "assigned_visits",
4
  "balance_workload",
5
  "minimize_travel",
6
  "priority_slack",
static/sf-config.json CHANGED
@@ -2,6 +2,7 @@
2
  "title": "Bergamo Field Service Routing",
3
  "subtitle": "Technician routes, road travel, time windows, skills, and parts",
4
  "constraints": [
 
5
  "reachable_legs",
6
  "required_skills",
7
  "required_parts",
 
2
  "title": "Bergamo Field Service Routing",
3
  "subtitle": "Technician routes, road travel, time windows, skills, and parts",
4
  "constraints": [
5
+ "assigned_visits",
6
  "reachable_legs",
7
  "required_skills",
8
  "required_parts",