Spaces:
Paused
Paused
File size: 6,447 Bytes
4f50b67 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 | //! Shared route measurements stored as technician-route shadow values.
//!
//! SolverForge calls each stock constraint separately, but the business
//! concepts overlap: travel, time windows, skills, parts, overtime, and priority
//! slack all require walking the same ordered visit list. This module
//! centralizes that walk so route entities can expose simple shadow fields to
//! the constraint builders.
use crate::domain::{FieldServicePlan, ServiceVisit, TechnicianRoute, TravelLeg};
/// Aggregated measurements for one technician route.
///
/// Individual constraints reuse this struct so each business rule can stay
/// small. For example, the time-window constraint reads `late_minutes`, while
/// the travel minimization rule reads `travel_seconds` and `distance_meters`.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct RouteStats {
pub invalid_visits: i64,
pub valid_visits: i64,
pub scored_travel_legs: i64,
pub unreachable_legs: i64,
pub missing_skill_visits: i64,
pub missing_part_visits: i64,
pub late_visits: i64,
pub late_minutes: i64,
pub overtime_minutes: i64,
pub travel_seconds: i64,
pub distance_meters: i64,
pub service_minutes: i64,
pub waiting_minutes: i64,
pub route_minutes: i64,
pub finish_minute: i32,
pub territory_matches: i64,
pub priority_slack: i64,
}
#[derive(Debug, Clone, Copy)]
struct VisitTiming {
visit_idx: usize,
service_start: i32,
}
pub fn route_stats(plan: &FieldServicePlan, route: &TechnicianRoute) -> RouteStats {
let mut stats = RouteStats {
finish_minute: route.shift_start_minute,
..RouteStats::default()
};
let mut clock = route.shift_start_minute;
let mut previous_location = route.start_location_idx;
let mut timings = Vec::with_capacity(route.visits.len());
// Walk the route in visit order. This mirrors how a technician would drive:
// depot to first visit, visit to visit, then back to the end depot.
for &visit_idx in &route.visits {
let Some(visit) = plan.service_visits.get(visit_idx) else {
stats.invalid_visits += 1;
continue;
};
stats.valid_visits += 1;
apply_leg(
plan,
previous_location,
visit.location_idx,
&mut clock,
&mut stats,
);
// Waiting is allowed and soft-neutral; lateness is a hard feasibility
// problem scored by the time-window constraint.
if clock < visit.earliest_minute {
stats.waiting_minutes += i64::from(visit.earliest_minute - clock);
clock = visit.earliest_minute;
}
if clock > visit.latest_minute {
stats.late_visits += 1;
stats.late_minutes += i64::from(clock - visit.latest_minute);
}
if !mask_contains(route.skill_mask, visit.required_skill_mask) {
stats.missing_skill_visits += 1;
}
if !mask_contains(route.inventory_mask, visit.required_parts_mask) {
stats.missing_part_visits += 1;
}
if route.territory == visit.territory {
stats.territory_matches += 1;
}
timings.push(VisitTiming {
visit_idx,
service_start: clock,
});
let service_minutes = visit.duration_minutes.max(0);
stats.service_minutes += i64::from(service_minutes);
clock = clock.saturating_add(service_minutes);
previous_location = visit.location_idx;
}
apply_leg(
plan,
previous_location,
route.end_location_idx,
&mut clock,
&mut stats,
);
stats.finish_minute = clock;
stats.route_minutes = i64::from(clock.saturating_sub(route.shift_start_minute));
stats.overtime_minutes = i64::from((clock - route.shift_end_minute).max(0))
+ (stats.route_minutes - i64::from(route.max_route_minutes)).max(0);
stats.priority_slack = priority_slack(plan, &timings);
stats
}
pub fn leg_for(
plan: &FieldServicePlan,
from_location_idx: usize,
to_location_idx: usize,
) -> Option<&TravelLeg> {
let width = plan.locations.len();
// Travel legs are normally stored as a dense row-major matrix. The secondary
// scan keeps tests and sparse diagnostics readable without changing the
// public fact shape.
let direct_idx = from_location_idx
.checked_mul(width)?
.checked_add(to_location_idx)?;
if let Some(leg) = plan.travel_legs.get(direct_idx) {
if leg.from_location_idx == from_location_idx && leg.to_location_idx == to_location_idx {
return Some(leg);
}
}
plan.travel_legs.iter().find(|leg| {
leg.from_location_idx == from_location_idx && leg.to_location_idx == to_location_idx
})
}
fn apply_leg(
plan: &FieldServicePlan,
from_location_idx: usize,
to_location_idx: usize,
clock: &mut i32,
stats: &mut RouteStats,
) {
let Some(leg) = leg_for(plan, from_location_idx, to_location_idx) else {
stats.unreachable_legs += 1;
return;
};
if !leg.reachable {
stats.unreachable_legs += 1;
return;
}
// Scoring uses seconds for precision but the route clock advances in whole
// minutes because visits and shifts are modeled on a minute calendar.
stats.travel_seconds += leg.duration_seconds.max(0);
stats.distance_meters += leg.distance_meters.max(0);
if leg.duration_seconds > 0 || leg.distance_meters > 0 {
stats.scored_travel_legs += 1;
}
*clock = clock.saturating_add(div_ceil(leg.duration_seconds.max(0), 60) as i32);
}
fn priority_slack(plan: &FieldServicePlan, timings: &[VisitTiming]) -> i64 {
timings
.iter()
.filter_map(|timing| {
plan.service_visits
.get(timing.visit_idx)
.map(|visit| visit_priority_slack(visit, timing.service_start))
})
.sum()
}
fn visit_priority_slack(visit: &ServiceVisit, service_start: i32) -> i64 {
let slack_quarters = i64::from((visit.latest_minute - service_start).max(0) / 15);
i64::from(visit.priority.max(1)) * (slack_quarters + 1)
}
fn mask_contains(available: i64, required: i64) -> bool {
(available & required) == required
}
fn div_ceil(value: i64, divisor: i64) -> i64 {
if value <= 0 {
0
} else {
(value + divisor - 1) / divisor
}
}
|