///|
/// One feasible placement before it becomes an assignment.
pub(all) struct Candidate {
worker_id : String
visit_id : String
start_minute : Int
end_minute : Int
travel_before_minutes : Int
raw_score : Int
reasons : Array[String]
} derive(Debug, Eq)
///|
/// Candidate search result retains rejected evaluations for diagnostics.
pub(all) struct CandidateSearch {
candidates : Array[Candidate]
rejected : Array[FeasibilityResult]
} derive(Debug, Eq)
///|
/// Soft score for one feasible candidate.
pub fn candidate_score(
worker : Worker,
visit : Visit,
start_minute : Int,
feasibility : FeasibilityResult,
assignments : Array[Assignment],
policy : SchedulePolicy,
) -> Int {
let priority = priority_value(visit.priority) * policy.priority_weight
let preference = if visit.prefers(worker.id) {
policy.preference_weight
} else {
0
}
let travel = feasibility.travel_before_minutes * policy.travel_weight
let load = assigned_minutes(assignments, worker.id) * policy.fairness_weight
let early = (start_minute - visit.window.start_minute) / 5
priority + preference - travel - load - early
}
///|
/// Explanations attached to one accepted candidate.
pub fn candidate_reasons(
worker : Worker,
visit : Visit,
feasibility : FeasibilityResult,
) -> Array[String] {
let reasons : Array[String] = []
reasons.push("all hard constraints satisfied")
if visit.prefers(worker.id) {
reasons.push("worker is preferred by the service request")
}
if visit.required_skills.length() > 0 {
reasons.push("worker satisfies every required skill")
} else {
reasons.push("visit has no specialist skill requirement")
}
if feasibility.travel_before_minutes == 0 {
reasons.push("no travel time is required before the visit")
} else {
reasons.push(
"estimated travel before visit is \{feasibility.travel_before_minutes} minutes",
)
}
if worker.home.zone == visit.location.zone {
reasons.push("visit is in the worker home service zone")
}
reasons
}
///|
/// Generate deterministic candidate start points for a visit.
pub fn candidate_start_minutes(
visit : Visit,
step_minutes? : Int = 15,
) -> Array[Int] {
let result : Array[Int] = []
let step = if step_minutes <= 0 { 15 } else { step_minutes }
let latest = visit.window.latest_start(visit.duration_minutes)
let mut minute = visit.window.start_minute
while minute <= latest {
result.push(minute)
minute = minute + step
}
if result.length() > 0 && result[result.length() - 1] != latest {
result.push(latest)
}
result
}
///|
/// Search every worker and start point for one visit.
pub fn search_candidates(
visit : Visit,
workers : Array[Worker],
assignments : Array[Assignment],
visits : Array[Visit],
policy : SchedulePolicy,
) -> CandidateSearch {
let candidates : Array[Candidate] = []
let rejected : Array[FeasibilityResult] = []
for worker in workers {
for start_minute in candidate_start_minutes(visit) {
let feasibility = evaluate_feasibility(
worker, visit, start_minute, assignments, visits, policy,
)
if feasibility.feasible {
candidates.push({
worker_id: worker.id,
visit_id: visit.id,
start_minute,
end_minute: start_minute + visit.duration_minutes,
travel_before_minutes: feasibility.travel_before_minutes,
raw_score: candidate_score(
worker, visit, start_minute, feasibility, assignments, policy,
),
reasons: candidate_reasons(worker, visit, feasibility),
})
} else {
rejected.push(feasibility)
}
}
}
{ candidates: sort_candidates(candidates), rejected }
}
///|
/// Sort by descending score with stable worker, start and visit tie breakers.
pub fn sort_candidates(values : Array[Candidate]) -> Array[Candidate] {
let sorted = copy_array(values)
for index = 1; index < sorted.length(); index = index + 1 {
let current = sorted[index]
let mut position = index
while position > 0 {
let previous = sorted[position - 1]
let current_before = current.raw_score > previous.raw_score ||
(
current.raw_score == previous.raw_score &&
current.worker_id < previous.worker_id
) ||
(
current.raw_score == previous.raw_score &&
current.worker_id == previous.worker_id &&
current.start_minute < previous.start_minute
) ||
(
current.raw_score == previous.raw_score &&
current.worker_id == previous.worker_id &&
current.start_minute == previous.start_minute &&
current.visit_id < previous.visit_id
)
if !current_before {
break
}
sorted[position] = previous
position = position - 1
}
sorted[position] = current
}
sorted
}
///|
/// Convert one candidate to the public assignment representation.
pub fn Candidate::to_assignment(self : Candidate) -> Assignment {
{
visit_id: self.visit_id,
worker_id: self.worker_id,
start_minute: self.start_minute,
end_minute: self.end_minute,
travel_before_minutes: self.travel_before_minutes,
score: self.raw_score,
reasons: copy_array(self.reasons),
}
}