blackopsrepl commited on
Commit
610cbd2
·
1 Parent(s): 66c0efc

feat(core): scale Bergamo routing model

Browse files

Split the oversized Rust seed, route-metric, and solver-service modules into focused files so every Rust source remains below the 300-line limit.

Scale the LARGE demo to 48 visits over the Bergamo service-location catalog while keeping generated routes initially unassigned. The route seed data now uses broader technician capability and full-day visit windows so the larger generated plan can become feasible during construction and local search.

src/constraints/mod.rs CHANGED
@@ -8,7 +8,10 @@ use solverforge::prelude::*;
8
 
9
  pub use self::assemble::create_constraints;
10
 
 
11
  pub mod route_metrics;
 
 
12
 
13
  // @solverforge:begin constraint-modules
14
  mod assigned_visits;
 
8
 
9
  pub use self::assemble::create_constraints;
10
 
11
+ mod route_constraint;
12
  pub mod route_metrics;
13
+ #[cfg(test)]
14
+ mod route_metrics_tests;
15
 
16
  // @solverforge:begin constraint-modules
17
  mod assigned_visits;
src/constraints/route_constraint.rs ADDED
@@ -0,0 +1,88 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ use crate::domain::{FieldServicePlan, TechnicianRoute};
2
+ use solverforge::prelude::*;
3
+ use solverforge::IncrementalConstraint;
4
+
5
+ pub struct RouteConstraint {
6
+ name: &'static str,
7
+ hard: bool,
8
+ weight: HardSoftScore,
9
+ scorer: fn(&FieldServicePlan, &TechnicianRoute) -> HardSoftScore,
10
+ }
11
+
12
+ impl RouteConstraint {
13
+ pub const fn new(
14
+ name: &'static str,
15
+ hard: bool,
16
+ weight: HardSoftScore,
17
+ scorer: fn(&FieldServicePlan, &TechnicianRoute) -> HardSoftScore,
18
+ ) -> Self {
19
+ Self {
20
+ name,
21
+ hard,
22
+ weight,
23
+ scorer,
24
+ }
25
+ }
26
+
27
+ fn route_score(&self, solution: &FieldServicePlan, entity_index: usize) -> HardSoftScore {
28
+ solution
29
+ .technician_routes
30
+ .get(entity_index)
31
+ .map(|route| (self.scorer)(solution, route))
32
+ .unwrap_or(HardSoftScore::ZERO)
33
+ }
34
+ }
35
+
36
+ impl IncrementalConstraint<FieldServicePlan, HardSoftScore> for RouteConstraint {
37
+ fn evaluate(&self, solution: &FieldServicePlan) -> HardSoftScore {
38
+ solution
39
+ .technician_routes
40
+ .iter()
41
+ .map(|route| (self.scorer)(solution, route))
42
+ .fold(HardSoftScore::ZERO, |total, score| total + score)
43
+ }
44
+
45
+ fn match_count(&self, solution: &FieldServicePlan) -> usize {
46
+ solution
47
+ .technician_routes
48
+ .iter()
49
+ .filter(|route| (self.scorer)(solution, route) != HardSoftScore::ZERO)
50
+ .count()
51
+ }
52
+
53
+ fn initialize(&mut self, solution: &FieldServicePlan) -> HardSoftScore {
54
+ self.evaluate(solution)
55
+ }
56
+
57
+ fn on_insert(
58
+ &mut self,
59
+ solution: &FieldServicePlan,
60
+ entity_index: usize,
61
+ _descriptor_index: usize,
62
+ ) -> HardSoftScore {
63
+ self.route_score(solution, entity_index)
64
+ }
65
+
66
+ fn on_retract(
67
+ &mut self,
68
+ solution: &FieldServicePlan,
69
+ entity_index: usize,
70
+ _descriptor_index: usize,
71
+ ) -> HardSoftScore {
72
+ -self.route_score(solution, entity_index)
73
+ }
74
+
75
+ fn reset(&mut self) {}
76
+
77
+ fn name(&self) -> &str {
78
+ self.name
79
+ }
80
+
81
+ fn is_hard(&self) -> bool {
82
+ self.hard
83
+ }
84
+
85
+ fn weight(&self) -> HardSoftScore {
86
+ self.weight
87
+ }
88
+ }
src/constraints/route_metrics.rs CHANGED
@@ -1,91 +1,7 @@
1
  use crate::domain::{FieldServicePlan, ServiceVisit, TechnicianRoute, TravelLeg};
2
  use solverforge::prelude::*;
3
- use solverforge::IncrementalConstraint;
4
 
5
- pub struct RouteConstraint {
6
- name: &'static str,
7
- hard: bool,
8
- weight: HardSoftScore,
9
- scorer: fn(&FieldServicePlan, &TechnicianRoute) -> HardSoftScore,
10
- }
11
-
12
- impl RouteConstraint {
13
- pub const fn new(
14
- name: &'static str,
15
- hard: bool,
16
- weight: HardSoftScore,
17
- scorer: fn(&FieldServicePlan, &TechnicianRoute) -> HardSoftScore,
18
- ) -> Self {
19
- Self {
20
- name,
21
- hard,
22
- weight,
23
- scorer,
24
- }
25
- }
26
-
27
- fn route_score(&self, solution: &FieldServicePlan, entity_index: usize) -> HardSoftScore {
28
- solution
29
- .technician_routes
30
- .get(entity_index)
31
- .map(|route| (self.scorer)(solution, route))
32
- .unwrap_or(HardSoftScore::ZERO)
33
- }
34
- }
35
-
36
- impl IncrementalConstraint<FieldServicePlan, HardSoftScore> for RouteConstraint {
37
- fn evaluate(&self, solution: &FieldServicePlan) -> HardSoftScore {
38
- solution
39
- .technician_routes
40
- .iter()
41
- .map(|route| (self.scorer)(solution, route))
42
- .fold(HardSoftScore::ZERO, |total, score| total + score)
43
- }
44
-
45
- fn match_count(&self, solution: &FieldServicePlan) -> usize {
46
- solution
47
- .technician_routes
48
- .iter()
49
- .filter(|route| (self.scorer)(solution, route) != HardSoftScore::ZERO)
50
- .count()
51
- }
52
-
53
- fn initialize(&mut self, solution: &FieldServicePlan) -> HardSoftScore {
54
- self.evaluate(solution)
55
- }
56
-
57
- fn on_insert(
58
- &mut self,
59
- solution: &FieldServicePlan,
60
- entity_index: usize,
61
- _descriptor_index: usize,
62
- ) -> HardSoftScore {
63
- self.route_score(solution, entity_index)
64
- }
65
-
66
- fn on_retract(
67
- &mut self,
68
- solution: &FieldServicePlan,
69
- entity_index: usize,
70
- _descriptor_index: usize,
71
- ) -> HardSoftScore {
72
- -self.route_score(solution, entity_index)
73
- }
74
-
75
- fn reset(&mut self) {}
76
-
77
- fn name(&self) -> &str {
78
- self.name
79
- }
80
-
81
- fn is_hard(&self) -> bool {
82
- self.hard
83
- }
84
-
85
- fn weight(&self) -> HardSoftScore {
86
- self.weight
87
- }
88
- }
89
 
90
  #[derive(Debug, Clone, Default, PartialEq, Eq)]
91
  pub struct RouteStats {
@@ -296,140 +212,3 @@ fn div_ceil(value: i64, divisor: i64) -> i64 {
296
  (value + divisor - 1) / divisor
297
  }
298
  }
299
-
300
- #[cfg(test)]
301
- mod tests {
302
- use super::*;
303
- use crate::domain::{
304
- FieldServicePlan, Location, ServiceVisit, ServiceVisitInit, TechnicianRoute,
305
- TechnicianRouteInit, TravelLeg, TravelLegInit,
306
- };
307
- use solverforge::ConstraintSet;
308
-
309
- #[test]
310
- fn route_stats_accounts_for_travel_service_and_lateness() {
311
- let plan = sample_plan(vec![0, 1]);
312
- let stats = route_stats(&plan, &plan.technician_routes[0]);
313
-
314
- assert_eq!(stats.travel_seconds, 1_800);
315
- assert_eq!(stats.service_minutes, 75);
316
- assert_eq!(stats.late_minutes, 0);
317
- assert_eq!(stats.route_minutes, 125);
318
- assert_eq!(stats.overtime_minutes, 55);
319
- assert_eq!(stats.missing_skill_visits, 0);
320
- assert_eq!(stats.missing_part_visits, 1);
321
- }
322
-
323
- #[test]
324
- fn travel_leg_lookup_prefers_row_major_contract() {
325
- let plan = sample_plan(vec![0]);
326
- let leg = leg_for(&plan, 0, 1).expect("leg should exist");
327
-
328
- assert_eq!(leg.id, "leg-0-1");
329
- assert!(leg.reachable);
330
- }
331
-
332
- #[test]
333
- fn full_constraint_set_reports_expected_hard_penalties() {
334
- let constraints = crate::constraints::create_constraints();
335
- let score = constraints.evaluate_all(&sample_plan(vec![0, 1]));
336
-
337
- assert_eq!(score.hard(), -56);
338
- assert!(score.soft() < 0);
339
- }
340
-
341
- fn sample_plan(visits: Vec<usize>) -> FieldServicePlan {
342
- let locations = vec![
343
- Location::new(
344
- "loc-0",
345
- "Hub",
346
- "Hub".to_string(),
347
- 45_700_000,
348
- 9_670_000,
349
- "depot".to_string(),
350
- ),
351
- Location::new(
352
- "loc-1",
353
- "Customer 1",
354
- "Customer 1".to_string(),
355
- 45_710_000,
356
- 9_680_000,
357
- "customer".to_string(),
358
- ),
359
- Location::new(
360
- "loc-2",
361
- "Customer 2",
362
- "Customer 2".to_string(),
363
- 45_720_000,
364
- 9_690_000,
365
- "customer".to_string(),
366
- ),
367
- ];
368
- let service_visits = vec![
369
- ServiceVisit::new(ServiceVisitInit {
370
- id: "visit-0".to_string(),
371
- name: "Boiler".to_string(),
372
- customer: "Customer 1".to_string(),
373
- location_idx: 1,
374
- duration_minutes: 30,
375
- earliest_minute: 510,
376
- latest_minute: 540,
377
- required_skill_mask: 0b001,
378
- required_parts_mask: 0b010,
379
- priority: 3,
380
- territory: "center".to_string(),
381
- }),
382
- ServiceVisit::new(ServiceVisitInit {
383
- id: "visit-1".to_string(),
384
- name: "Lift".to_string(),
385
- customer: "Customer 2".to_string(),
386
- location_idx: 2,
387
- duration_minutes: 45,
388
- earliest_minute: 540,
389
- latest_minute: 570,
390
- required_skill_mask: 0b001,
391
- required_parts_mask: 0b100,
392
- priority: 2,
393
- territory: "center".to_string(),
394
- }),
395
- ];
396
- let travel_legs = row_major_legs(3);
397
- let mut route = TechnicianRoute::new(TechnicianRouteInit {
398
- id: "route-0".to_string(),
399
- technician_id: "tech-0".to_string(),
400
- technician_name: "Ada".to_string(),
401
- color: "#2563eb".to_string(),
402
- start_location_idx: 0,
403
- end_location_idx: 0,
404
- shift_start_minute: 480,
405
- shift_end_minute: 585,
406
- max_route_minutes: 90,
407
- skill_mask: 0b001,
408
- inventory_mask: 0b010,
409
- territory: "center".to_string(),
410
- });
411
- route.visits = visits;
412
-
413
- FieldServicePlan::new(locations, service_visits, travel_legs, vec![route])
414
- }
415
-
416
- fn row_major_legs(width: usize) -> Vec<TravelLeg> {
417
- (0..width)
418
- .flat_map(|from| {
419
- (0..width).map(move |to| {
420
- let same = from == to;
421
- TravelLeg::new(TravelLegInit {
422
- id: format!("leg-{from}-{to}"),
423
- name: format!("leg-{from}-{to}"),
424
- from_location_idx: from,
425
- to_location_idx: to,
426
- duration_seconds: if same { 0 } else { 600 },
427
- distance_meters: if same { 0 } else { 2_000 },
428
- encoded_polyline: String::new(),
429
- reachable: true,
430
- })
431
- })
432
- })
433
- .collect()
434
- }
435
- }
 
1
  use crate::domain::{FieldServicePlan, ServiceVisit, TechnicianRoute, TravelLeg};
2
  use solverforge::prelude::*;
 
3
 
4
+ pub use super::route_constraint::RouteConstraint;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5
 
6
  #[derive(Debug, Clone, Default, PartialEq, Eq)]
7
  pub struct RouteStats {
 
212
  (value + divisor - 1) / divisor
213
  }
214
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/constraints/route_metrics_tests.rs ADDED
@@ -0,0 +1,133 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ use super::route_metrics::{leg_for, route_stats};
2
+ use crate::domain::{
3
+ FieldServicePlan, Location, ServiceVisit, ServiceVisitInit, TechnicianRoute,
4
+ TechnicianRouteInit, TravelLeg, TravelLegInit,
5
+ };
6
+ use solverforge::ConstraintSet;
7
+
8
+ #[test]
9
+ fn route_stats_accounts_for_travel_service_and_lateness() {
10
+ let plan = sample_plan(vec![0, 1]);
11
+ let stats = route_stats(&plan, &plan.technician_routes[0]);
12
+
13
+ assert_eq!(stats.travel_seconds, 1_800);
14
+ assert_eq!(stats.service_minutes, 75);
15
+ assert_eq!(stats.late_minutes, 0);
16
+ assert_eq!(stats.route_minutes, 125);
17
+ assert_eq!(stats.overtime_minutes, 55);
18
+ assert_eq!(stats.missing_skill_visits, 0);
19
+ assert_eq!(stats.missing_part_visits, 1);
20
+ }
21
+
22
+ #[test]
23
+ fn travel_leg_lookup_prefers_row_major_contract() {
24
+ let plan = sample_plan(vec![0]);
25
+ let leg = leg_for(&plan, 0, 1).expect("leg should exist");
26
+
27
+ assert_eq!(leg.id, "leg-0-1");
28
+ assert!(leg.reachable);
29
+ }
30
+
31
+ #[test]
32
+ fn full_constraint_set_reports_expected_hard_penalties() {
33
+ let constraints = crate::constraints::create_constraints();
34
+ let score = constraints.evaluate_all(&sample_plan(vec![0, 1]));
35
+
36
+ assert_eq!(score.hard(), -56);
37
+ assert!(score.soft() < 0);
38
+ }
39
+
40
+ fn sample_plan(visits: Vec<usize>) -> FieldServicePlan {
41
+ let locations = vec![
42
+ Location::new(
43
+ "loc-0",
44
+ "Hub",
45
+ "Hub".to_string(),
46
+ 45_700_000,
47
+ 9_670_000,
48
+ "depot".to_string(),
49
+ ),
50
+ Location::new(
51
+ "loc-1",
52
+ "Customer 1",
53
+ "Customer 1".to_string(),
54
+ 45_710_000,
55
+ 9_680_000,
56
+ "customer".to_string(),
57
+ ),
58
+ Location::new(
59
+ "loc-2",
60
+ "Customer 2",
61
+ "Customer 2".to_string(),
62
+ 45_720_000,
63
+ 9_690_000,
64
+ "customer".to_string(),
65
+ ),
66
+ ];
67
+ let service_visits = vec![
68
+ ServiceVisit::new(ServiceVisitInit {
69
+ id: "visit-0".to_string(),
70
+ name: "Boiler".to_string(),
71
+ customer: "Customer 1".to_string(),
72
+ location_idx: 1,
73
+ duration_minutes: 30,
74
+ earliest_minute: 510,
75
+ latest_minute: 540,
76
+ required_skill_mask: 0b001,
77
+ required_parts_mask: 0b010,
78
+ priority: 3,
79
+ territory: "center".to_string(),
80
+ }),
81
+ ServiceVisit::new(ServiceVisitInit {
82
+ id: "visit-1".to_string(),
83
+ name: "Lift".to_string(),
84
+ customer: "Customer 2".to_string(),
85
+ location_idx: 2,
86
+ duration_minutes: 45,
87
+ earliest_minute: 540,
88
+ latest_minute: 570,
89
+ required_skill_mask: 0b001,
90
+ required_parts_mask: 0b100,
91
+ priority: 2,
92
+ territory: "center".to_string(),
93
+ }),
94
+ ];
95
+ let travel_legs = row_major_legs(3);
96
+ let mut route = TechnicianRoute::new(TechnicianRouteInit {
97
+ id: "route-0".to_string(),
98
+ technician_id: "tech-0".to_string(),
99
+ technician_name: "Ada".to_string(),
100
+ color: "#2563eb".to_string(),
101
+ start_location_idx: 0,
102
+ end_location_idx: 0,
103
+ shift_start_minute: 480,
104
+ shift_end_minute: 585,
105
+ max_route_minutes: 90,
106
+ skill_mask: 0b001,
107
+ inventory_mask: 0b010,
108
+ territory: "center".to_string(),
109
+ });
110
+ route.visits = visits;
111
+
112
+ FieldServicePlan::new(locations, service_visits, travel_legs, vec![route])
113
+ }
114
+
115
+ fn row_major_legs(width: usize) -> Vec<TravelLeg> {
116
+ (0..width)
117
+ .flat_map(|from| {
118
+ (0..width).map(move |to| {
119
+ let same = from == to;
120
+ TravelLeg::new(TravelLegInit {
121
+ id: format!("leg-{from}-{to}"),
122
+ name: format!("leg-{from}-{to}"),
123
+ from_location_idx: from,
124
+ to_location_idx: to,
125
+ duration_seconds: if same { 0 } else { 600 },
126
+ distance_meters: if same { 0 } else { 2_000 },
127
+ encoded_polyline: String::new(),
128
+ reachable: true,
129
+ })
130
+ })
131
+ })
132
+ .collect()
133
+ }
src/data/bergamo_catalog.rs ADDED
@@ -0,0 +1,60 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ use crate::domain::Location;
2
+
3
+ #[derive(Clone, Copy)]
4
+ pub(super) struct LocationSeed {
5
+ pub id: &'static str,
6
+ pub label: &'static str,
7
+ pub lat: f64,
8
+ pub lng: f64,
9
+ pub territory: &'static str,
10
+ }
11
+
12
+ impl LocationSeed {
13
+ pub(super) fn to_location(self, kind: &'static str) -> Location {
14
+ Location::new(
15
+ self.id,
16
+ self.label,
17
+ self.label.to_string(),
18
+ coord_e6(self.lat),
19
+ coord_e6(self.lng),
20
+ kind.to_string(),
21
+ )
22
+ }
23
+ }
24
+
25
+ #[derive(Clone, Copy)]
26
+ pub(super) struct VisitProfile {
27
+ pub name: &'static str,
28
+ pub duration_minutes: i32,
29
+ pub earliest_minute: i32,
30
+ pub latest_minute: i32,
31
+ pub required_skill_mask: i64,
32
+ pub required_parts_mask: i64,
33
+ pub priority: i32,
34
+ }
35
+
36
+ #[derive(Clone, Copy)]
37
+ pub(super) struct TechnicianSeed {
38
+ pub id: &'static str,
39
+ pub name: &'static str,
40
+ pub color: &'static str,
41
+ pub start_location_idx: usize,
42
+ pub end_location_idx: usize,
43
+ pub skill_mask: i64,
44
+ pub inventory_mask: i64,
45
+ pub territory: &'static str,
46
+ }
47
+
48
+ pub(super) const SKILL_HVAC: i64 = 0b0001;
49
+ pub(super) const SKILL_ELECTRICAL: i64 = 0b0010;
50
+ pub(super) const SKILL_PLUMBING: i64 = 0b0100;
51
+ pub(super) const SKILL_ELEVATOR: i64 = 0b1000;
52
+
53
+ pub(super) const PART_FILTERS: i64 = 0b0001;
54
+ pub(super) const PART_RELAYS: i64 = 0b0010;
55
+ pub(super) const PART_VALVES: i64 = 0b0100;
56
+ pub(super) const PART_SENSORS: i64 = 0b1000;
57
+
58
+ fn coord_e6(value: f64) -> i32 {
59
+ (value * 1_000_000.0).round() as i32
60
+ }
src/data/bergamo_locations.rs ADDED
@@ -0,0 +1,189 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ use super::bergamo_catalog::LocationSeed;
2
+
3
+ pub(super) const DEPOTS: &[LocationSeed] = &[
4
+ LocationSeed {
5
+ id: "depot-ops",
6
+ label: "Bergamo Operations Hub",
7
+ lat: 45.6954,
8
+ lng: 9.6703,
9
+ territory: "center",
10
+ },
11
+ LocationSeed {
12
+ id: "depot-east",
13
+ label: "Seriate Parts Locker",
14
+ lat: 45.6835,
15
+ lng: 9.7210,
16
+ territory: "east",
17
+ },
18
+ ];
19
+
20
+ pub(super) const SERVICE_LOCATIONS: &[LocationSeed] = &[
21
+ LocationSeed {
22
+ id: "loc-citta-alta",
23
+ label: "Citta Alta heating fault",
24
+ lat: 45.7036,
25
+ lng: 9.6627,
26
+ territory: "north",
27
+ },
28
+ LocationSeed {
29
+ id: "loc-borgo-palazzo",
30
+ label: "Borgo Palazzo refrigeration",
31
+ lat: 45.6903,
32
+ lng: 9.6909,
33
+ territory: "east",
34
+ },
35
+ LocationSeed {
36
+ id: "loc-stazione",
37
+ label: "Station kiosk power",
38
+ lat: 45.6900,
39
+ lng: 9.6750,
40
+ territory: "center",
41
+ },
42
+ LocationSeed {
43
+ id: "loc-longuelo",
44
+ label: "Longuelo pump service",
45
+ lat: 45.6982,
46
+ lng: 9.6377,
47
+ territory: "west",
48
+ },
49
+ LocationSeed {
50
+ id: "loc-redona",
51
+ label: "Redona lift inspection",
52
+ lat: 45.7107,
53
+ lng: 9.6999,
54
+ territory: "north",
55
+ },
56
+ LocationSeed {
57
+ id: "loc-celadina",
58
+ label: "Celadina controls alarm",
59
+ lat: 45.6815,
60
+ lng: 9.7056,
61
+ territory: "east",
62
+ },
63
+ LocationSeed {
64
+ id: "loc-valtesse",
65
+ label: "Valtesse boiler reset",
66
+ lat: 45.7202,
67
+ lng: 9.6736,
68
+ territory: "north",
69
+ },
70
+ LocationSeed {
71
+ id: "loc-colognola",
72
+ label: "Colognola valve leak",
73
+ lat: 45.6767,
74
+ lng: 9.6469,
75
+ territory: "south",
76
+ },
77
+ LocationSeed {
78
+ id: "loc-malpensata",
79
+ label: "Malpensata sensor swap",
80
+ lat: 45.6840,
81
+ lng: 9.6687,
82
+ territory: "south",
83
+ },
84
+ LocationSeed {
85
+ id: "loc-seriate",
86
+ label: "Seriate medical cooler",
87
+ lat: 45.6856,
88
+ lng: 9.7242,
89
+ territory: "east",
90
+ },
91
+ LocationSeed {
92
+ id: "loc-gorle",
93
+ label: "Gorle access control",
94
+ lat: 45.7014,
95
+ lng: 9.7138,
96
+ territory: "east",
97
+ },
98
+ LocationSeed {
99
+ id: "loc-treviglio-road",
100
+ label: "Azzano workshop air unit",
101
+ lat: 45.6579,
102
+ lng: 9.6734,
103
+ territory: "south",
104
+ },
105
+ LocationSeed {
106
+ id: "loc-monterosso",
107
+ label: "Monterosso lift callout",
108
+ lat: 45.7161,
109
+ lng: 9.6905,
110
+ territory: "north",
111
+ },
112
+ LocationSeed {
113
+ id: "loc-loreto",
114
+ label: "Loreto electrical board",
115
+ lat: 45.6995,
116
+ lng: 9.6517,
117
+ territory: "west",
118
+ },
119
+ LocationSeed {
120
+ id: "loc-stezzano",
121
+ label: "Stezzano retail HVAC",
122
+ lat: 45.6508,
123
+ lng: 9.6534,
124
+ territory: "south",
125
+ },
126
+ LocationSeed {
127
+ id: "loc-grumello",
128
+ label: "Grumello pressure issue",
129
+ lat: 45.6888,
130
+ lng: 9.6275,
131
+ territory: "west",
132
+ },
133
+ LocationSeed {
134
+ id: "loc-orio",
135
+ label: "Orio terminal chiller",
136
+ lat: 45.6689,
137
+ lng: 9.7044,
138
+ territory: "south",
139
+ },
140
+ LocationSeed {
141
+ id: "loc-ranica",
142
+ label: "Ranica municipal lift",
143
+ lat: 45.7241,
144
+ lng: 9.7133,
145
+ territory: "north",
146
+ },
147
+ LocationSeed {
148
+ id: "loc-torre-boldone",
149
+ label: "Torre Boldone boiler",
150
+ lat: 45.7178,
151
+ lng: 9.7075,
152
+ territory: "north",
153
+ },
154
+ LocationSeed {
155
+ id: "loc-villaggio-sposi",
156
+ label: "Villaggio Sposi pump",
157
+ lat: 45.6901,
158
+ lng: 9.6365,
159
+ territory: "west",
160
+ },
161
+ LocationSeed {
162
+ id: "loc-dalmine",
163
+ label: "Dalmine line sensor",
164
+ lat: 45.6482,
165
+ lng: 9.6061,
166
+ territory: "west",
167
+ },
168
+ LocationSeed {
169
+ id: "loc-alzano",
170
+ label: "Alzano Lombardo relay",
171
+ lat: 45.7362,
172
+ lng: 9.7271,
173
+ territory: "north",
174
+ },
175
+ LocationSeed {
176
+ id: "loc-ponte-san-pietro",
177
+ label: "Ponte San Pietro valve",
178
+ lat: 45.7001,
179
+ lng: 9.5908,
180
+ territory: "west",
181
+ },
182
+ LocationSeed {
183
+ id: "loc-scanzo",
184
+ label: "Scanzorosciate cooler",
185
+ lat: 45.7105,
186
+ lng: 9.7354,
187
+ territory: "east",
188
+ },
189
+ ];
src/data/bergamo_profiles.rs ADDED
@@ -0,0 +1,61 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ use super::bergamo_catalog::{
2
+ VisitProfile, PART_RELAYS, PART_SENSORS, PART_VALVES, SKILL_ELECTRICAL, SKILL_ELEVATOR,
3
+ SKILL_HVAC, SKILL_PLUMBING,
4
+ };
5
+
6
+ pub(super) const VISIT_PROFILES: &[VisitProfile] = &[
7
+ VisitProfile {
8
+ name: "Boiler restart",
9
+ duration_minutes: 35,
10
+ earliest_minute: 8 * 60,
11
+ latest_minute: 18 * 60,
12
+ required_skill_mask: SKILL_HVAC,
13
+ required_parts_mask: PART_SENSORS,
14
+ priority: 4,
15
+ },
16
+ VisitProfile {
17
+ name: "Refrigeration diagnosis",
18
+ duration_minutes: 45,
19
+ earliest_minute: 9 * 60,
20
+ latest_minute: 18 * 60,
21
+ required_skill_mask: SKILL_HVAC | SKILL_ELECTRICAL,
22
+ required_parts_mask: PART_RELAYS,
23
+ priority: 5,
24
+ },
25
+ VisitProfile {
26
+ name: "Electrical board check",
27
+ duration_minutes: 30,
28
+ earliest_minute: 8 * 60 + 30,
29
+ latest_minute: 18 * 60,
30
+ required_skill_mask: SKILL_ELECTRICAL,
31
+ required_parts_mask: PART_RELAYS,
32
+ priority: 3,
33
+ },
34
+ VisitProfile {
35
+ name: "Pump service",
36
+ duration_minutes: 50,
37
+ earliest_minute: 10 * 60,
38
+ latest_minute: 18 * 60,
39
+ required_skill_mask: SKILL_PLUMBING,
40
+ required_parts_mask: PART_VALVES,
41
+ priority: 3,
42
+ },
43
+ VisitProfile {
44
+ name: "Lift safety inspection",
45
+ duration_minutes: 60,
46
+ earliest_minute: 11 * 60,
47
+ latest_minute: 18 * 60,
48
+ required_skill_mask: SKILL_ELEVATOR | SKILL_ELECTRICAL,
49
+ required_parts_mask: PART_SENSORS,
50
+ priority: 4,
51
+ },
52
+ VisitProfile {
53
+ name: "Controls alarm reset",
54
+ duration_minutes: 25,
55
+ earliest_minute: 13 * 60,
56
+ latest_minute: 18 * 60,
57
+ required_skill_mask: SKILL_ELECTRICAL,
58
+ required_parts_mask: PART_SENSORS,
59
+ priority: 2,
60
+ },
61
+ ];
src/data/bergamo_technicians.rs ADDED
@@ -0,0 +1,70 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ use super::bergamo_catalog::{
2
+ TechnicianSeed, PART_FILTERS, PART_RELAYS, PART_SENSORS, PART_VALVES, SKILL_ELECTRICAL,
3
+ SKILL_ELEVATOR, SKILL_HVAC, SKILL_PLUMBING,
4
+ };
5
+
6
+ pub(super) const TECHNICIANS: &[TechnicianSeed] = &[
7
+ TechnicianSeed {
8
+ id: "tech-ada",
9
+ name: "Ada Romano",
10
+ color: "#2563eb",
11
+ start_location_idx: 0,
12
+ end_location_idx: 0,
13
+ skill_mask: ALL_SKILLS,
14
+ inventory_mask: ALL_PARTS,
15
+ territory: "center",
16
+ },
17
+ TechnicianSeed {
18
+ id: "tech-marco",
19
+ name: "Marco Bianchi",
20
+ color: "#059669",
21
+ start_location_idx: 1,
22
+ end_location_idx: 1,
23
+ skill_mask: ALL_SKILLS,
24
+ inventory_mask: ALL_PARTS,
25
+ territory: "east",
26
+ },
27
+ TechnicianSeed {
28
+ id: "tech-elena",
29
+ name: "Elena Conti",
30
+ color: "#d97706",
31
+ start_location_idx: 0,
32
+ end_location_idx: 0,
33
+ skill_mask: ALL_SKILLS,
34
+ inventory_mask: ALL_PARTS,
35
+ territory: "north",
36
+ },
37
+ TechnicianSeed {
38
+ id: "tech-paolo",
39
+ name: "Paolo Gatti",
40
+ color: "#be123c",
41
+ start_location_idx: 0,
42
+ end_location_idx: 0,
43
+ skill_mask: ALL_SKILLS,
44
+ inventory_mask: ALL_PARTS,
45
+ territory: "west",
46
+ },
47
+ TechnicianSeed {
48
+ id: "tech-sara",
49
+ name: "Sara Ferri",
50
+ color: "#7c3aed",
51
+ start_location_idx: 1,
52
+ end_location_idx: 1,
53
+ skill_mask: ALL_SKILLS,
54
+ inventory_mask: ALL_PARTS,
55
+ territory: "south",
56
+ },
57
+ TechnicianSeed {
58
+ id: "tech-luca",
59
+ name: "Luca Moretti",
60
+ color: "#0f766e",
61
+ start_location_idx: 0,
62
+ end_location_idx: 0,
63
+ skill_mask: ALL_SKILLS,
64
+ inventory_mask: ALL_PARTS,
65
+ territory: "east",
66
+ },
67
+ ];
68
+
69
+ const ALL_SKILLS: i64 = SKILL_ELECTRICAL | SKILL_ELEVATOR | SKILL_HVAC | SKILL_PLUMBING;
70
+ const ALL_PARTS: i64 = PART_FILTERS | PART_RELAYS | PART_SENSORS | PART_VALVES;
src/data/data_seed.rs CHANGED
@@ -7,6 +7,9 @@ use solverforge_maps::{
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,
@@ -87,7 +90,7 @@ impl DemoData {
87
  match self {
88
  Self::Small => 6,
89
  Self::Standard => 14,
90
- Self::Large => SERVICE_LOCATIONS.len(),
91
  }
92
  }
93
  }
@@ -135,30 +138,30 @@ fn network_config() -> NetworkConfig {
135
  }
136
 
137
  fn build_locations(demo: DemoData) -> Vec<Location> {
 
 
138
  DEPOTS
139
  .iter()
140
  .map(|seed| seed.to_location("depot"))
141
  .chain(
142
  SERVICE_LOCATIONS
143
  .iter()
144
- .take(demo.visit_count())
145
  .map(|seed| seed.to_location("customer")),
146
  )
147
  .collect()
148
  }
149
 
150
  fn build_service_visits(demo: DemoData) -> Vec<ServiceVisit> {
151
- SERVICE_LOCATIONS
152
- .iter()
153
- .take(demo.visit_count())
154
- .enumerate()
155
- .map(|(idx, seed)| {
156
  let profile = VISIT_PROFILES[idx % VISIT_PROFILES.len()];
157
  ServiceVisit::new(ServiceVisitInit {
158
  id: format!("visit-{idx:02}"),
159
  name: profile.name.to_string(),
160
  customer: seed.label.to_string(),
161
- location_idx: DEPOTS.len() + idx,
162
  duration_minutes: profile.duration_minutes,
163
  earliest_minute: profile.earliest_minute,
164
  latest_minute: profile.latest_minute,
@@ -185,8 +188,8 @@ fn build_technician_routes(demo: DemoData) -> Vec<TechnicianRoute> {
185
  start_location_idx: seed.start_location_idx,
186
  end_location_idx: seed.end_location_idx,
187
  shift_start_minute: 8 * 60,
188
- shift_end_minute: 17 * 60,
189
- max_route_minutes: 8 * 60,
190
  skill_mask: seed.skill_mask,
191
  inventory_mask: seed.inventory_mask,
192
  territory: seed.territory.to_string(),
@@ -245,373 +248,6 @@ fn build_travel_legs(
245
  legs
246
  }
247
 
248
- #[derive(Clone, Copy)]
249
- struct LocationSeed {
250
- id: &'static str,
251
- label: &'static str,
252
- lat: f64,
253
- lng: f64,
254
- territory: &'static str,
255
- }
256
-
257
- impl LocationSeed {
258
- fn to_location(self, kind: &'static str) -> Location {
259
- Location::new(
260
- self.id,
261
- self.label,
262
- self.label.to_string(),
263
- coord_e6(self.lat),
264
- coord_e6(self.lng),
265
- kind.to_string(),
266
- )
267
- }
268
- }
269
-
270
- fn coord_e6(value: f64) -> i32 {
271
- (value * 1_000_000.0).round() as i32
272
- }
273
-
274
- #[derive(Clone, Copy)]
275
- struct VisitProfile {
276
- name: &'static str,
277
- duration_minutes: i32,
278
- earliest_minute: i32,
279
- latest_minute: i32,
280
- required_skill_mask: i64,
281
- required_parts_mask: i64,
282
- priority: i32,
283
- }
284
-
285
- #[derive(Clone, Copy)]
286
- struct TechnicianSeed {
287
- id: &'static str,
288
- name: &'static str,
289
- color: &'static str,
290
- start_location_idx: usize,
291
- end_location_idx: usize,
292
- skill_mask: i64,
293
- inventory_mask: i64,
294
- territory: &'static str,
295
- }
296
-
297
- const SKILL_HVAC: i64 = 0b0001;
298
- const SKILL_ELECTRICAL: i64 = 0b0010;
299
- const SKILL_PLUMBING: i64 = 0b0100;
300
- const SKILL_ELEVATOR: i64 = 0b1000;
301
-
302
- const PART_FILTERS: i64 = 0b0001;
303
- const PART_RELAYS: i64 = 0b0010;
304
- const PART_VALVES: i64 = 0b0100;
305
- const PART_SENSORS: i64 = 0b1000;
306
-
307
- const DEPOTS: &[LocationSeed] = &[
308
- LocationSeed {
309
- id: "depot-ops",
310
- label: "Bergamo Operations Hub",
311
- lat: 45.6954,
312
- lng: 9.6703,
313
- territory: "center",
314
- },
315
- LocationSeed {
316
- id: "depot-east",
317
- label: "Seriate Parts Locker",
318
- lat: 45.6835,
319
- lng: 9.7210,
320
- territory: "east",
321
- },
322
- ];
323
-
324
- const SERVICE_LOCATIONS: &[LocationSeed] = &[
325
- LocationSeed {
326
- id: "loc-citta-alta",
327
- label: "Citta Alta heating fault",
328
- lat: 45.7036,
329
- lng: 9.6627,
330
- territory: "north",
331
- },
332
- LocationSeed {
333
- id: "loc-borgo-palazzo",
334
- label: "Borgo Palazzo refrigeration",
335
- lat: 45.6903,
336
- lng: 9.6909,
337
- territory: "east",
338
- },
339
- LocationSeed {
340
- id: "loc-stazione",
341
- label: "Station kiosk power",
342
- lat: 45.6900,
343
- lng: 9.6750,
344
- territory: "center",
345
- },
346
- LocationSeed {
347
- id: "loc-longuelo",
348
- label: "Longuelo pump service",
349
- lat: 45.6982,
350
- lng: 9.6377,
351
- territory: "west",
352
- },
353
- LocationSeed {
354
- id: "loc-redona",
355
- label: "Redona lift inspection",
356
- lat: 45.7107,
357
- lng: 9.6999,
358
- territory: "north",
359
- },
360
- LocationSeed {
361
- id: "loc-celadina",
362
- label: "Celadina controls alarm",
363
- lat: 45.6815,
364
- lng: 9.7056,
365
- territory: "east",
366
- },
367
- LocationSeed {
368
- id: "loc-valtesse",
369
- label: "Valtesse boiler reset",
370
- lat: 45.7202,
371
- lng: 9.6736,
372
- territory: "north",
373
- },
374
- LocationSeed {
375
- id: "loc-colognola",
376
- label: "Colognola valve leak",
377
- lat: 45.6767,
378
- lng: 9.6469,
379
- territory: "south",
380
- },
381
- LocationSeed {
382
- id: "loc-malpensata",
383
- label: "Malpensata sensor swap",
384
- lat: 45.6840,
385
- lng: 9.6687,
386
- territory: "south",
387
- },
388
- LocationSeed {
389
- id: "loc-seriate",
390
- label: "Seriate medical cooler",
391
- lat: 45.6856,
392
- lng: 9.7242,
393
- territory: "east",
394
- },
395
- LocationSeed {
396
- id: "loc-gorle",
397
- label: "Gorle access control",
398
- lat: 45.7014,
399
- lng: 9.7138,
400
- territory: "east",
401
- },
402
- LocationSeed {
403
- id: "loc-treviglio-road",
404
- label: "Azzano workshop air unit",
405
- lat: 45.6579,
406
- lng: 9.6734,
407
- territory: "south",
408
- },
409
- LocationSeed {
410
- id: "loc-monterosso",
411
- label: "Monterosso lift callout",
412
- lat: 45.7161,
413
- lng: 9.6905,
414
- territory: "north",
415
- },
416
- LocationSeed {
417
- id: "loc-loreto",
418
- label: "Loreto electrical board",
419
- lat: 45.6995,
420
- lng: 9.6517,
421
- territory: "west",
422
- },
423
- LocationSeed {
424
- id: "loc-stezzano",
425
- label: "Stezzano retail HVAC",
426
- lat: 45.6508,
427
- lng: 9.6534,
428
- territory: "south",
429
- },
430
- LocationSeed {
431
- id: "loc-grumello",
432
- label: "Grumello pressure issue",
433
- lat: 45.6888,
434
- lng: 9.6275,
435
- territory: "west",
436
- },
437
- LocationSeed {
438
- id: "loc-orio",
439
- label: "Orio terminal chiller",
440
- lat: 45.6689,
441
- lng: 9.7044,
442
- territory: "south",
443
- },
444
- LocationSeed {
445
- id: "loc-ranica",
446
- label: "Ranica municipal lift",
447
- lat: 45.7241,
448
- lng: 9.7133,
449
- territory: "north",
450
- },
451
- LocationSeed {
452
- id: "loc-torre-boldone",
453
- label: "Torre Boldone boiler",
454
- lat: 45.7178,
455
- lng: 9.7075,
456
- territory: "north",
457
- },
458
- LocationSeed {
459
- id: "loc-villaggio-sposi",
460
- label: "Villaggio Sposi pump",
461
- lat: 45.6901,
462
- lng: 9.6365,
463
- territory: "west",
464
- },
465
- LocationSeed {
466
- id: "loc-dalmine",
467
- label: "Dalmine line sensor",
468
- lat: 45.6482,
469
- lng: 9.6061,
470
- territory: "west",
471
- },
472
- LocationSeed {
473
- id: "loc-alzano",
474
- label: "Alzano Lombardo relay",
475
- lat: 45.7362,
476
- lng: 9.7271,
477
- territory: "north",
478
- },
479
- LocationSeed {
480
- id: "loc-ponte-san-pietro",
481
- label: "Ponte San Pietro valve",
482
- lat: 45.7001,
483
- lng: 9.5908,
484
- territory: "west",
485
- },
486
- LocationSeed {
487
- id: "loc-scanzo",
488
- label: "Scanzorosciate cooler",
489
- lat: 45.7105,
490
- lng: 9.7354,
491
- territory: "east",
492
- },
493
- ];
494
-
495
- const VISIT_PROFILES: &[VisitProfile] = &[
496
- VisitProfile {
497
- name: "Boiler restart",
498
- duration_minutes: 35,
499
- earliest_minute: 8 * 60,
500
- latest_minute: 10 * 60,
501
- required_skill_mask: SKILL_HVAC,
502
- required_parts_mask: PART_SENSORS,
503
- priority: 4,
504
- },
505
- VisitProfile {
506
- name: "Refrigeration diagnosis",
507
- duration_minutes: 45,
508
- earliest_minute: 9 * 60,
509
- latest_minute: 12 * 60,
510
- required_skill_mask: SKILL_HVAC | SKILL_ELECTRICAL,
511
- required_parts_mask: PART_RELAYS,
512
- priority: 5,
513
- },
514
- VisitProfile {
515
- name: "Electrical board check",
516
- duration_minutes: 30,
517
- earliest_minute: 8 * 60 + 30,
518
- latest_minute: 13 * 60,
519
- required_skill_mask: SKILL_ELECTRICAL,
520
- required_parts_mask: PART_RELAYS,
521
- priority: 3,
522
- },
523
- VisitProfile {
524
- name: "Pump service",
525
- duration_minutes: 50,
526
- earliest_minute: 10 * 60,
527
- latest_minute: 15 * 60,
528
- required_skill_mask: SKILL_PLUMBING,
529
- required_parts_mask: PART_VALVES,
530
- priority: 3,
531
- },
532
- VisitProfile {
533
- name: "Lift safety inspection",
534
- duration_minutes: 60,
535
- earliest_minute: 11 * 60,
536
- latest_minute: 16 * 60,
537
- required_skill_mask: SKILL_ELEVATOR | SKILL_ELECTRICAL,
538
- required_parts_mask: PART_SENSORS,
539
- priority: 4,
540
- },
541
- VisitProfile {
542
- name: "Controls alarm reset",
543
- duration_minutes: 25,
544
- earliest_minute: 13 * 60,
545
- latest_minute: 17 * 60,
546
- required_skill_mask: SKILL_ELECTRICAL,
547
- required_parts_mask: PART_SENSORS,
548
- priority: 2,
549
- },
550
- ];
551
-
552
- const TECHNICIANS: &[TechnicianSeed] = &[
553
- TechnicianSeed {
554
- id: "tech-ada",
555
- name: "Ada Romano",
556
- color: "#2563eb",
557
- start_location_idx: 0,
558
- end_location_idx: 0,
559
- skill_mask: SKILL_HVAC | SKILL_ELECTRICAL,
560
- inventory_mask: PART_FILTERS | PART_RELAYS | PART_SENSORS,
561
- territory: "center",
562
- },
563
- TechnicianSeed {
564
- id: "tech-marco",
565
- name: "Marco Bianchi",
566
- color: "#059669",
567
- start_location_idx: 1,
568
- end_location_idx: 1,
569
- skill_mask: SKILL_PLUMBING | SKILL_HVAC,
570
- inventory_mask: PART_VALVES | PART_FILTERS | PART_SENSORS,
571
- territory: "east",
572
- },
573
- TechnicianSeed {
574
- id: "tech-elena",
575
- name: "Elena Conti",
576
- color: "#d97706",
577
- start_location_idx: 0,
578
- end_location_idx: 0,
579
- skill_mask: SKILL_ELEVATOR | SKILL_ELECTRICAL,
580
- inventory_mask: PART_RELAYS | PART_SENSORS,
581
- territory: "north",
582
- },
583
- TechnicianSeed {
584
- id: "tech-paolo",
585
- name: "Paolo Gatti",
586
- color: "#be123c",
587
- start_location_idx: 0,
588
- end_location_idx: 0,
589
- skill_mask: SKILL_PLUMBING | SKILL_ELECTRICAL | SKILL_HVAC,
590
- inventory_mask: PART_VALVES | PART_RELAYS | PART_FILTERS,
591
- territory: "west",
592
- },
593
- TechnicianSeed {
594
- id: "tech-sara",
595
- name: "Sara Ferri",
596
- color: "#7c3aed",
597
- start_location_idx: 1,
598
- end_location_idx: 1,
599
- skill_mask: SKILL_HVAC | SKILL_ELECTRICAL | SKILL_ELEVATOR,
600
- inventory_mask: PART_RELAYS | PART_SENSORS | PART_FILTERS,
601
- territory: "south",
602
- },
603
- TechnicianSeed {
604
- id: "tech-luca",
605
- name: "Luca Moretti",
606
- color: "#0f766e",
607
- start_location_idx: 0,
608
- end_location_idx: 0,
609
- skill_mask: SKILL_PLUMBING | SKILL_HVAC | SKILL_ELECTRICAL,
610
- inventory_mask: PART_VALVES | PART_RELAYS | PART_SENSORS | PART_FILTERS,
611
- territory: "east",
612
- },
613
- ];
614
-
615
  #[cfg(test)]
616
  mod tests {
617
  use super::*;
 
7
  encode_polyline, BoundingBox, Coord, NetworkConfig, RoadNetwork, RoutingError, UNREACHABLE,
8
  };
9
 
10
+ use super::bergamo_locations::{DEPOTS, SERVICE_LOCATIONS};
11
+ use super::bergamo_profiles::VISIT_PROFILES;
12
+ use super::bergamo_technicians::TECHNICIANS;
13
  use crate::domain::{
14
  FieldServicePlan, Location, ServiceVisit, ServiceVisitInit, TechnicianRoute,
15
  TechnicianRouteInit, TravelLeg, TravelLegInit,
 
90
  match self {
91
  Self::Small => 6,
92
  Self::Standard => 14,
93
+ Self::Large => SERVICE_LOCATIONS.len() * 2,
94
  }
95
  }
96
  }
 
138
  }
139
 
140
  fn build_locations(demo: DemoData) -> Vec<Location> {
141
+ let service_location_count = demo.visit_count().min(SERVICE_LOCATIONS.len());
142
+
143
  DEPOTS
144
  .iter()
145
  .map(|seed| seed.to_location("depot"))
146
  .chain(
147
  SERVICE_LOCATIONS
148
  .iter()
149
+ .take(service_location_count)
150
  .map(|seed| seed.to_location("customer")),
151
  )
152
  .collect()
153
  }
154
 
155
  fn build_service_visits(demo: DemoData) -> Vec<ServiceVisit> {
156
+ (0..demo.visit_count())
157
+ .map(|idx| {
158
+ let seed = &SERVICE_LOCATIONS[idx % SERVICE_LOCATIONS.len()];
 
 
159
  let profile = VISIT_PROFILES[idx % VISIT_PROFILES.len()];
160
  ServiceVisit::new(ServiceVisitInit {
161
  id: format!("visit-{idx:02}"),
162
  name: profile.name.to_string(),
163
  customer: seed.label.to_string(),
164
+ location_idx: DEPOTS.len() + (idx % SERVICE_LOCATIONS.len()),
165
  duration_minutes: profile.duration_minutes,
166
  earliest_minute: profile.earliest_minute,
167
  latest_minute: profile.latest_minute,
 
188
  start_location_idx: seed.start_location_idx,
189
  end_location_idx: seed.end_location_idx,
190
  shift_start_minute: 8 * 60,
191
+ shift_end_minute: 18 * 60,
192
+ max_route_minutes: 10 * 60,
193
  skill_mask: seed.skill_mask,
194
  inventory_mask: seed.inventory_mask,
195
  territory: seed.territory.to_string(),
 
248
  legs
249
  }
250
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
251
  #[cfg(test)]
252
  mod tests {
253
  use super::*;
src/data/mod.rs CHANGED
@@ -1,3 +1,7 @@
 
 
 
 
1
  mod data_seed;
2
 
3
  pub use data_seed::{available_demo_data, default_demo_data, generate, DemoData, DemoDataError};
 
1
+ mod bergamo_catalog;
2
+ mod bergamo_locations;
3
+ mod bergamo_profiles;
4
+ mod bergamo_technicians;
5
  mod data_seed;
6
 
7
  pub use data_seed::{available_demo_data, default_demo_data, generate, DemoData, DemoDataError};
src/solver/event_payload.rs ADDED
@@ -0,0 +1,199 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ use serde::Serialize;
2
+ use std::time::Duration;
3
+
4
+ use solverforge::{
5
+ HardSoftScore, SolverEventMetadata, SolverLifecycleState, SolverSnapshot, SolverStatus,
6
+ SolverTelemetry, SolverTerminalReason,
7
+ };
8
+
9
+ use crate::api::PlanDto;
10
+ use crate::domain::FieldServicePlan;
11
+
12
+ #[derive(Serialize)]
13
+ #[serde(rename_all = "camelCase")]
14
+ struct TelemetryPayload {
15
+ elapsed_ms: u64,
16
+ step_count: u64,
17
+ moves_generated: u64,
18
+ moves_evaluated: u64,
19
+ moves_accepted: u64,
20
+ score_calculations: u64,
21
+ generation_ms: u64,
22
+ evaluation_ms: u64,
23
+ moves_per_second: u64,
24
+ acceptance_rate: f64,
25
+ }
26
+
27
+ #[derive(Serialize)]
28
+ #[serde(rename_all = "camelCase")]
29
+ struct JobEventPayload {
30
+ id: String,
31
+ job_id: String,
32
+ event_type: &'static str,
33
+ event_sequence: u64,
34
+ lifecycle_state: &'static str,
35
+ terminal_reason: Option<&'static str>,
36
+ telemetry: TelemetryPayload,
37
+ current_score: Option<String>,
38
+ best_score: Option<String>,
39
+ snapshot_revision: Option<u64>,
40
+ solution: Option<PlanDto>,
41
+ error: Option<String>,
42
+ }
43
+
44
+ pub(super) fn status_event_payload(
45
+ job_id: usize,
46
+ event_type: &'static str,
47
+ status: &SolverStatus<HardSoftScore>,
48
+ ) -> String {
49
+ serialize_payload(JobEventPayload {
50
+ id: job_id.to_string(),
51
+ job_id: job_id.to_string(),
52
+ event_type,
53
+ event_sequence: status.event_sequence,
54
+ lifecycle_state: lifecycle_state_label(status.lifecycle_state),
55
+ terminal_reason: status.terminal_reason.map(terminal_reason_label),
56
+ telemetry: telemetry_payload(&status.telemetry),
57
+ current_score: status.current_score.map(|score| score.to_string()),
58
+ best_score: status.best_score.map(|score| score.to_string()),
59
+ snapshot_revision: status.latest_snapshot_revision,
60
+ solution: None,
61
+ error: None,
62
+ })
63
+ }
64
+
65
+ pub(super) fn snapshot_status_event_payload(
66
+ job_id: usize,
67
+ event_type: &'static str,
68
+ status: &SolverStatus<HardSoftScore>,
69
+ snapshot: &SolverSnapshot<FieldServicePlan>,
70
+ ) -> String {
71
+ serialize_payload(JobEventPayload {
72
+ id: job_id.to_string(),
73
+ job_id: job_id.to_string(),
74
+ event_type,
75
+ event_sequence: status.event_sequence,
76
+ lifecycle_state: lifecycle_state_label(status.lifecycle_state),
77
+ terminal_reason: status.terminal_reason.map(terminal_reason_label),
78
+ telemetry: telemetry_payload(&status.telemetry),
79
+ current_score: status
80
+ .current_score
81
+ .or(snapshot.current_score)
82
+ .map(|score| score.to_string()),
83
+ best_score: status
84
+ .best_score
85
+ .or(snapshot.best_score)
86
+ .map(|score| score.to_string()),
87
+ snapshot_revision: Some(snapshot.snapshot_revision),
88
+ solution: Some(PlanDto::from_plan(&snapshot.solution)),
89
+ error: None,
90
+ })
91
+ }
92
+
93
+ pub(super) fn event_payload(
94
+ job_id: usize,
95
+ event_type: &'static str,
96
+ metadata: &SolverEventMetadata<HardSoftScore>,
97
+ solution: Option<&FieldServicePlan>,
98
+ error: Option<&str>,
99
+ ) -> String {
100
+ serialize_payload(JobEventPayload {
101
+ id: job_id.to_string(),
102
+ job_id: job_id.to_string(),
103
+ event_type,
104
+ event_sequence: metadata.event_sequence,
105
+ lifecycle_state: lifecycle_state_label(metadata.lifecycle_state),
106
+ terminal_reason: metadata.terminal_reason.map(terminal_reason_label),
107
+ telemetry: telemetry_payload(&metadata.telemetry),
108
+ current_score: metadata.current_score.map(|score| score.to_string()),
109
+ best_score: metadata.best_score.map(|score| score.to_string()),
110
+ snapshot_revision: metadata.snapshot_revision,
111
+ solution: solution.map(PlanDto::from_plan),
112
+ error: error.map(ToOwned::to_owned),
113
+ })
114
+ }
115
+
116
+ pub(super) fn bootstrap_event_type(state: SolverLifecycleState) -> &'static str {
117
+ match state {
118
+ SolverLifecycleState::Solving => "progress",
119
+ SolverLifecycleState::PauseRequested => "pause_requested",
120
+ SolverLifecycleState::Paused => "paused",
121
+ SolverLifecycleState::Completed => "completed",
122
+ SolverLifecycleState::Cancelled => "cancelled",
123
+ SolverLifecycleState::Failed => "failed",
124
+ }
125
+ }
126
+
127
+ pub(super) fn bootstrap_snapshot_event_type(state: SolverLifecycleState) -> &'static str {
128
+ match state {
129
+ SolverLifecycleState::Solving => "best_solution",
130
+ other => bootstrap_event_type(other),
131
+ }
132
+ }
133
+
134
+ fn serialize_payload(payload: JobEventPayload) -> String {
135
+ serde_json::to_string(&payload).expect("failed to serialize solver lifecycle payload")
136
+ }
137
+
138
+ fn telemetry_payload(telemetry: &SolverTelemetry) -> TelemetryPayload {
139
+ TelemetryPayload {
140
+ elapsed_ms: duration_to_millis(telemetry.elapsed),
141
+ step_count: telemetry.step_count,
142
+ moves_generated: telemetry.moves_generated,
143
+ moves_evaluated: telemetry.moves_evaluated,
144
+ moves_accepted: telemetry.moves_accepted,
145
+ score_calculations: telemetry.score_calculations,
146
+ generation_ms: duration_to_millis(telemetry.generation_time),
147
+ evaluation_ms: duration_to_millis(telemetry.evaluation_time),
148
+ moves_per_second: whole_units_per_second(telemetry.moves_evaluated, telemetry.elapsed),
149
+ acceptance_rate: derive_acceptance_rate(
150
+ telemetry.moves_accepted,
151
+ telemetry.moves_evaluated,
152
+ ),
153
+ }
154
+ }
155
+
156
+ fn lifecycle_state_label(state: SolverLifecycleState) -> &'static str {
157
+ match state {
158
+ SolverLifecycleState::Solving => "SOLVING",
159
+ SolverLifecycleState::PauseRequested => "PAUSE_REQUESTED",
160
+ SolverLifecycleState::Paused => "PAUSED",
161
+ SolverLifecycleState::Completed => "COMPLETED",
162
+ SolverLifecycleState::Cancelled => "CANCELLED",
163
+ SolverLifecycleState::Failed => "FAILED",
164
+ }
165
+ }
166
+
167
+ fn terminal_reason_label(reason: SolverTerminalReason) -> &'static str {
168
+ match reason {
169
+ SolverTerminalReason::Completed => "completed",
170
+ SolverTerminalReason::TerminatedByConfig => "terminated_by_config",
171
+ SolverTerminalReason::Cancelled => "cancelled",
172
+ SolverTerminalReason::Failed => "failed",
173
+ }
174
+ }
175
+
176
+ fn duration_to_millis(duration: Duration) -> u64 {
177
+ duration.as_millis().min(u128::from(u64::MAX)) as u64
178
+ }
179
+
180
+ fn whole_units_per_second(count: u64, elapsed: Duration) -> u64 {
181
+ let nanos = elapsed.as_nanos();
182
+ if nanos == 0 {
183
+ 0
184
+ } else {
185
+ let per_second = u128::from(count)
186
+ .saturating_mul(1_000_000_000)
187
+ .checked_div(nanos)
188
+ .unwrap_or(0);
189
+ per_second.min(u128::from(u64::MAX)) as u64
190
+ }
191
+ }
192
+
193
+ fn derive_acceptance_rate(moves_accepted: u64, moves_evaluated: u64) -> f64 {
194
+ if moves_evaluated == 0 {
195
+ 0.0
196
+ } else {
197
+ moves_accepted as f64 / moves_evaluated as f64
198
+ }
199
+ }
src/solver/mod.rs CHANGED
@@ -1,3 +1,4 @@
 
1
  mod service;
2
 
3
  pub use service::SolverService;
 
1
+ mod event_payload;
2
  mod service;
3
 
4
  pub use service::SolverService;
src/solver/service.rs CHANGED
@@ -1,54 +1,22 @@
1
  use parking_lot::RwLock;
2
- use serde::Serialize;
3
  use std::collections::HashMap;
4
  use std::sync::Arc;
5
- use std::time::Duration;
6
  use tokio::sync::{broadcast, mpsc};
7
 
8
  use solverforge::{
9
- HardSoftScore, SolverEvent, SolverEventMetadata, SolverLifecycleState, SolverManager,
10
- SolverManagerError, SolverSnapshot, SolverSnapshotAnalysis, SolverStatus, SolverTelemetry,
11
- SolverTerminalReason,
12
  };
13
 
14
- use crate::api::PlanDto;
 
 
 
15
  use crate::domain::FieldServicePlan;
16
 
17
  // Static manager — must be 'static for retained job execution.
18
  static MANAGER: SolverManager<FieldServicePlan> = SolverManager::new();
19
 
20
- #[derive(Serialize)]
21
- #[serde(rename_all = "camelCase")]
22
- struct TelemetryPayload {
23
- elapsed_ms: u64,
24
- step_count: u64,
25
- moves_generated: u64,
26
- moves_evaluated: u64,
27
- moves_accepted: u64,
28
- score_calculations: u64,
29
- generation_ms: u64,
30
- evaluation_ms: u64,
31
- moves_per_second: u64,
32
- acceptance_rate: f64,
33
- }
34
-
35
- #[derive(Serialize)]
36
- #[serde(rename_all = "camelCase")]
37
- struct JobEventPayload {
38
- id: String,
39
- job_id: String,
40
- event_type: &'static str,
41
- event_sequence: u64,
42
- lifecycle_state: &'static str,
43
- terminal_reason: Option<&'static str>,
44
- telemetry: TelemetryPayload,
45
- current_score: Option<String>,
46
- best_score: Option<String>,
47
- snapshot_revision: Option<u64>,
48
- solution: Option<PlanDto>,
49
- error: Option<String>,
50
- }
51
-
52
  struct JobState {
53
  sse_tx: broadcast::Sender<String>,
54
  }
@@ -200,165 +168,8 @@ fn parse_job_id(id: &str) -> Result<usize, SolverManagerError> {
200
  .map_err(|_| SolverManagerError::JobNotFound { job_id: usize::MAX })
201
  }
202
 
203
- fn status_event_payload(
204
- job_id: usize,
205
- event_type: &'static str,
206
- status: &SolverStatus<HardSoftScore>,
207
- ) -> String {
208
- serialize_payload(JobEventPayload {
209
- id: job_id.to_string(),
210
- job_id: job_id.to_string(),
211
- event_type,
212
- event_sequence: status.event_sequence,
213
- lifecycle_state: lifecycle_state_label(status.lifecycle_state),
214
- terminal_reason: status.terminal_reason.map(terminal_reason_label),
215
- telemetry: telemetry_payload(&status.telemetry),
216
- current_score: status.current_score.map(|score| score.to_string()),
217
- best_score: status.best_score.map(|score| score.to_string()),
218
- snapshot_revision: status.latest_snapshot_revision,
219
- solution: None,
220
- error: None,
221
- })
222
- }
223
-
224
- fn snapshot_status_event_payload(
225
- job_id: usize,
226
- event_type: &'static str,
227
- status: &SolverStatus<HardSoftScore>,
228
- snapshot: &SolverSnapshot<FieldServicePlan>,
229
- ) -> String {
230
- serialize_payload(JobEventPayload {
231
- id: job_id.to_string(),
232
- job_id: job_id.to_string(),
233
- event_type,
234
- event_sequence: status.event_sequence,
235
- lifecycle_state: lifecycle_state_label(status.lifecycle_state),
236
- terminal_reason: status.terminal_reason.map(terminal_reason_label),
237
- telemetry: telemetry_payload(&status.telemetry),
238
- current_score: status
239
- .current_score
240
- .or(snapshot.current_score)
241
- .map(|score| score.to_string()),
242
- best_score: status
243
- .best_score
244
- .or(snapshot.best_score)
245
- .map(|score| score.to_string()),
246
- snapshot_revision: Some(snapshot.snapshot_revision),
247
- solution: Some(PlanDto::from_plan(&snapshot.solution)),
248
- error: None,
249
- })
250
- }
251
-
252
- fn bootstrap_event_type(state: SolverLifecycleState) -> &'static str {
253
- match state {
254
- SolverLifecycleState::Solving => "progress",
255
- SolverLifecycleState::PauseRequested => "pause_requested",
256
- SolverLifecycleState::Paused => "paused",
257
- SolverLifecycleState::Completed => "completed",
258
- SolverLifecycleState::Cancelled => "cancelled",
259
- SolverLifecycleState::Failed => "failed",
260
- }
261
- }
262
-
263
- fn bootstrap_snapshot_event_type(state: SolverLifecycleState) -> &'static str {
264
- match state {
265
- SolverLifecycleState::Solving => "best_solution",
266
- other => bootstrap_event_type(other),
267
- }
268
- }
269
-
270
- fn event_payload(
271
- job_id: usize,
272
- event_type: &'static str,
273
- metadata: &SolverEventMetadata<HardSoftScore>,
274
- solution: Option<&FieldServicePlan>,
275
- error: Option<&str>,
276
- ) -> String {
277
- serialize_payload(JobEventPayload {
278
- id: job_id.to_string(),
279
- job_id: job_id.to_string(),
280
- event_type,
281
- event_sequence: metadata.event_sequence,
282
- lifecycle_state: lifecycle_state_label(metadata.lifecycle_state),
283
- terminal_reason: metadata.terminal_reason.map(terminal_reason_label),
284
- telemetry: telemetry_payload(&metadata.telemetry),
285
- current_score: metadata.current_score.map(|score| score.to_string()),
286
- best_score: metadata.best_score.map(|score| score.to_string()),
287
- snapshot_revision: metadata.snapshot_revision,
288
- solution: solution.map(PlanDto::from_plan),
289
- error: error.map(ToOwned::to_owned),
290
- })
291
- }
292
-
293
- fn serialize_payload(payload: JobEventPayload) -> String {
294
- serde_json::to_string(&payload).expect("failed to serialize solver lifecycle payload")
295
- }
296
-
297
- fn telemetry_payload(telemetry: &SolverTelemetry) -> TelemetryPayload {
298
- TelemetryPayload {
299
- elapsed_ms: duration_to_millis(telemetry.elapsed),
300
- step_count: telemetry.step_count,
301
- moves_generated: telemetry.moves_generated,
302
- moves_evaluated: telemetry.moves_evaluated,
303
- moves_accepted: telemetry.moves_accepted,
304
- score_calculations: telemetry.score_calculations,
305
- generation_ms: duration_to_millis(telemetry.generation_time),
306
- evaluation_ms: duration_to_millis(telemetry.evaluation_time),
307
- moves_per_second: whole_units_per_second(telemetry.moves_evaluated, telemetry.elapsed),
308
- acceptance_rate: derive_acceptance_rate(
309
- telemetry.moves_accepted,
310
- telemetry.moves_evaluated,
311
- ),
312
- }
313
- }
314
-
315
- fn lifecycle_state_label(state: SolverLifecycleState) -> &'static str {
316
- match state {
317
- SolverLifecycleState::Solving => "SOLVING",
318
- SolverLifecycleState::PauseRequested => "PAUSE_REQUESTED",
319
- SolverLifecycleState::Paused => "PAUSED",
320
- SolverLifecycleState::Completed => "COMPLETED",
321
- SolverLifecycleState::Cancelled => "CANCELLED",
322
- SolverLifecycleState::Failed => "FAILED",
323
- }
324
- }
325
-
326
- fn terminal_reason_label(reason: SolverTerminalReason) -> &'static str {
327
- match reason {
328
- SolverTerminalReason::Completed => "completed",
329
- SolverTerminalReason::TerminatedByConfig => "terminated_by_config",
330
- SolverTerminalReason::Cancelled => "cancelled",
331
- SolverTerminalReason::Failed => "failed",
332
- }
333
- }
334
-
335
  impl Default for SolverService {
336
  fn default() -> Self {
337
  Self::new()
338
  }
339
  }
340
-
341
- fn duration_to_millis(duration: Duration) -> u64 {
342
- duration.as_millis().min(u128::from(u64::MAX)) as u64
343
- }
344
-
345
- fn whole_units_per_second(count: u64, elapsed: Duration) -> u64 {
346
- let nanos = elapsed.as_nanos();
347
- if nanos == 0 {
348
- 0
349
- } else {
350
- let per_second = u128::from(count)
351
- .saturating_mul(1_000_000_000)
352
- .checked_div(nanos)
353
- .unwrap_or(0);
354
- per_second.min(u128::from(u64::MAX)) as u64
355
- }
356
- }
357
-
358
- fn derive_acceptance_rate(moves_accepted: u64, moves_evaluated: u64) -> f64 {
359
- if moves_evaluated == 0 {
360
- 0.0
361
- } else {
362
- moves_accepted as f64 / moves_evaluated as f64
363
- }
364
- }
 
1
  use parking_lot::RwLock;
 
2
  use std::collections::HashMap;
3
  use std::sync::Arc;
 
4
  use tokio::sync::{broadcast, mpsc};
5
 
6
  use solverforge::{
7
+ HardSoftScore, SolverEvent, SolverManager, SolverManagerError, SolverSnapshot,
8
+ SolverSnapshotAnalysis, SolverStatus,
 
9
  };
10
 
11
+ use super::event_payload::{
12
+ bootstrap_event_type, bootstrap_snapshot_event_type, event_payload,
13
+ snapshot_status_event_payload, status_event_payload,
14
+ };
15
  use crate::domain::FieldServicePlan;
16
 
17
  // Static manager — must be 'static for retained job execution.
18
  static MANAGER: SolverManager<FieldServicePlan> = SolverManager::new();
19
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
20
  struct JobState {
21
  sse_tx: broadcast::Sender<String>,
22
  }
 
168
  .map_err(|_| SolverManagerError::JobNotFound { job_id: usize::MAX })
169
  }
170
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
171
  impl Default for SolverService {
172
  fn default() -> Self {
173
  Self::new()
174
  }
175
  }