///|
pub(all) enum ViolationKind {
UnknownWorker
UnknownShift
DuplicateAssignment
WorkerUnavailable
ExplicitlyUnavailable
MissingRequiredPreference
OverlappingShift
InsufficientRest
WeeklyMinuteLimit
ConsecutiveShiftLimit
MissingSkill
Understaffed
SkillShortage
} derive(Eq, Show, ToJson)
///|
pub struct ConstraintViolation {
kind : ViolationKind
message : String
worker_id : String?
shift_id : String?
hard : Bool
magnitude : Int
} derive(Eq, Show, ToJson)
///|
pub fn ConstraintViolation::new(
kind : ViolationKind,
message : String,
hard : Bool,
) -> ConstraintViolation {
{ kind, message, worker_id: None, shift_id: None, hard, magnitude: 1 }
}
///|
pub fn ConstraintViolation::for_worker(
self : ConstraintViolation,
worker_id : String,
) -> ConstraintViolation {
{ ..self, worker_id: Some(worker_id) }
}
///|
pub fn ConstraintViolation::for_shift(
self : ConstraintViolation,
shift_id : String,
) -> ConstraintViolation {
{ ..self, shift_id: Some(shift_id) }
}
///|
pub fn ConstraintViolation::with_magnitude(
self : ConstraintViolation,
magnitude : Int,
) -> ConstraintViolation {
{ ..self, magnitude, }
}
///|
pub struct ConstraintReport {
violations : Array[ConstraintViolation]
hard_count : Int
soft_count : Int
} derive(Eq, Show, ToJson)
///|
pub fn ConstraintReport::empty() -> ConstraintReport {
{ violations: [], hard_count: 0, soft_count: 0 }
}
///|
pub fn ConstraintReport::add(
self : ConstraintReport,
violation : ConstraintViolation,
) -> ConstraintReport {
let violations = self.violations.copy()
violations.push(violation)
if violation.hard {
{ ..self, violations, hard_count: self.hard_count + 1 }
} else {
{ ..self, violations, soft_count: self.soft_count + 1 }
}
}
///|
pub fn ConstraintReport::append(
self : ConstraintReport,
other : ConstraintReport,
) -> ConstraintReport {
let mut result = self
for violation in other.violations {
result = result.add(violation)
}
result
}
///|
pub fn ConstraintReport::feasible(self : ConstraintReport) -> Bool {
self.hard_count == 0
}
///|
pub fn worker_assigned_minutes(
problem : PlanningProblem,
schedule : Schedule,
worker_id : String,
) -> Int {
let mut total = 0
for assignment in schedule.assignments {
if assignment.worker_id == worker_id {
match problem.shift(assignment.shift_id) {
Some(shift) => total = total + shift.range.duration()
None => ()
}
}
}
total
}
///|
pub fn worker_night_count(
problem : PlanningProblem,
schedule : Schedule,
worker_id : String,
) -> Int {
let mut total = 0
for assignment in schedule.assignments {
if assignment.worker_id == worker_id {
match problem.shift(assignment.shift_id) {
Some(shift) => if shift.kind.is_night() { total = total + 1 }
None => ()
}
}
}
total
}
///|
pub fn worker_weekend_count(
problem : PlanningProblem,
schedule : Schedule,
worker_id : String,
) -> Int {
let mut total = 0
for assignment in schedule.assignments {
if assignment.worker_id == worker_id {
match problem.shift(assignment.shift_id) {
Some(shift) => if shift.weekend { total = total + 1 }
None => ()
}
}
}
total
}
///|
pub fn assignment_count_for_shift(
schedule : Schedule,
shift_id : String,
) -> Int {
let mut count = 0
for assignment in schedule.assignments {
if assignment.shift_id == shift_id {
count = count + 1
}
}
count
}
///|
fn assigned_shifts_for_worker(
problem : PlanningProblem,
schedule : Schedule,
worker_id : String,
) -> Array[Shift] {
let shifts : Array[Shift] = []
for assignment in schedule.assignments {
if assignment.worker_id == worker_id {
match problem.shift(assignment.shift_id) {
Some(shift) => shifts.push(shift)
None => ()
}
}
}
shifts
}
///|
fn ranges_overlap_existing(
problem : PlanningProblem,
schedule : Schedule,
worker_id : String,
candidate : Shift,
) -> Shift? {
for shift in assigned_shifts_for_worker(problem, schedule, worker_id) {
if shift.id != candidate.id && shift.range.overlaps(candidate.range) {
return Some(shift)
}
}
None
}
///|
fn insufficient_rest_with_existing(
problem : PlanningProblem,
schedule : Schedule,
worker : Worker,
candidate : Shift,
) -> Shift? {
for shift in assigned_shifts_for_worker(problem, schedule, worker.id) {
if shift.id != candidate.id && !shift.range.overlaps(candidate.range) {
let gap = if shift.range.end.value() <= candidate.range.start.value() {
shift.range.end.distance_to(candidate.range.start)
} else {
candidate.range.end.distance_to(shift.range.start)
}
if gap < worker.min_rest_minutes {
return Some(shift)
}
}
}
None
}
///|
fn consecutive_days_with_candidate(
problem : PlanningProblem,
schedule : Schedule,
worker_id : String,
candidate : Shift,
) -> Int {
let days : Array[Int] = [candidate.day_index]
for shift in assigned_shifts_for_worker(problem, schedule, worker_id) {
let mut duplicate = false
for day in days {
if day == shift.day_index {
duplicate = true
}
}
if !duplicate {
days.push(shift.day_index)
}
}
let mut best = 1
for base in days {
let mut length = 1
let mut previous = base
while true {
let mut found = false
for day in days {
if day == previous + 1 {
found = true
previous = day
length = length + 1
}
}
if !found {
break
}
}
if length > best {
best = length
}
}
best
}
///|
fn preference_violation(worker : Worker, shift : Shift) -> ConstraintViolation? {
match worker.preference_for(shift.id) {
Some(preference) =>
match preference.kind {
Unavailable =>
Some(
ConstraintViolation::new(
ExplicitlyUnavailable,
worker.name + " marked " + shift.label + " as unavailable",
true,
)
.for_worker(worker.id)
.for_shift(shift.id),
)
_ => None
}
None => None
}
}
///|
pub fn check_candidate(
problem : PlanningProblem,
schedule : Schedule,
worker_id : String,
shift_id : String,
) -> ConstraintReport {
let mut report = ConstraintReport::empty()
let worker = match problem.worker(worker_id) {
Some(worker) => worker
None =>
return report.add(
ConstraintViolation::new(
UnknownWorker,
"unknown worker: " + worker_id,
true,
).for_worker(worker_id),
)
}
let shift = match problem.shift(shift_id) {
Some(shift) => shift
None =>
return report.add(
ConstraintViolation::new(
UnknownShift,
"unknown shift: " + shift_id,
true,
).for_shift(shift_id),
)
}
if schedule.contains(worker_id, shift_id) {
report = report.add(
ConstraintViolation::new(
DuplicateAssignment,
worker.name + " is already assigned to " + shift.label,
true,
)
.for_worker(worker_id)
.for_shift(shift_id),
)
}
if !worker.is_available(shift.range) {
report = report.add(
ConstraintViolation::new(
WorkerUnavailable,
worker.name + " is not available for " + shift.label,
true,
)
.for_worker(worker_id)
.for_shift(shift_id),
)
}
match preference_violation(worker, shift) {
Some(violation) => report = report.add(violation)
None => ()
}
match ranges_overlap_existing(problem, schedule, worker_id, shift) {
Some(other) =>
report = report.add(
ConstraintViolation::new(
OverlappingShift,
shift.label + " overlaps " + other.label,
true,
)
.for_worker(worker_id)
.for_shift(shift_id),
)
None => ()
}
match insufficient_rest_with_existing(problem, schedule, worker, shift) {
Some(other) =>
report = report.add(
ConstraintViolation::new(
InsufficientRest,
worker.name + " would not receive enough rest around " + other.label,
true,
)
.for_worker(worker_id)
.for_shift(shift_id),
)
None => ()
}
let projected_minutes = worker_assigned_minutes(problem, schedule, worker_id) +
shift.range.duration()
if projected_minutes > worker.max_minutes {
report = report.add(
ConstraintViolation::new(
WeeklyMinuteLimit,
worker.name + " would exceed the maximum minute limit",
true,
)
.for_worker(worker_id)
.for_shift(shift_id)
.with_magnitude(projected_minutes - worker.max_minutes),
)
}
let consecutive = consecutive_days_with_candidate(
problem, schedule, worker_id, shift,
)
if consecutive > worker.max_consecutive_shifts {
report = report.add(
ConstraintViolation::new(
ConsecutiveShiftLimit,
worker.name + " would exceed the consecutive-day limit",
true,
)
.for_worker(worker_id)
.for_shift(shift_id)
.with_magnitude(consecutive - worker.max_consecutive_shifts),
)
}
report
}
///|
fn check_assignment_references(
problem : PlanningProblem,
schedule : Schedule,
) -> ConstraintReport {
let mut report = ConstraintReport::empty()
for assignment in schedule.assignments {
if problem.worker(assignment.worker_id) is None {
report = report.add(
ConstraintViolation::new(
UnknownWorker,
"assignment references unknown worker: " + assignment.worker_id,
true,
).for_worker(assignment.worker_id),
)
}
if problem.shift(assignment.shift_id) is None {
report = report.add(
ConstraintViolation::new(
UnknownShift,
"assignment references unknown shift: " + assignment.shift_id,
true,
).for_shift(assignment.shift_id),
)
}
}
report
}
///|
fn check_duplicates(schedule : Schedule) -> ConstraintReport {
let mut report = ConstraintReport::empty()
for i = 0; i < schedule.assignments.length(); i = i + 1 {
for j = i + 1; j < schedule.assignments.length(); j = j + 1 {
let left = schedule.assignments[i]
let right = schedule.assignments[j]
if left.worker_id == right.worker_id && left.shift_id == right.shift_id {
report = report.add(
ConstraintViolation::new(
DuplicateAssignment,
"duplicate assignment for worker " + left.worker_id,
true,
)
.for_worker(left.worker_id)
.for_shift(left.shift_id),
)
}
}
}
report
}
///|
fn check_worker_assignments(
problem : PlanningProblem,
schedule : Schedule,
) -> ConstraintReport {
let mut report = ConstraintReport::empty()
let incremental = Schedule::empty()
for assignment in schedule.assignments {
let candidate_report = check_candidate(
problem,
incremental,
assignment.worker_id,
assignment.shift_id,
)
report = report.append(candidate_report)
if candidate_report.feasible() {
incremental.assignments.push(assignment)
}
}
report
}
///|
fn count_qualified_workers(
problem : PlanningProblem,
schedule : Schedule,
shift : Shift,
requirement : SkillRequirement,
) -> Int {
let mut count = 0
for assignment in schedule.assignments {
if assignment.shift_id == shift.id {
match problem.worker(assignment.worker_id) {
Some(worker) =>
if worker.has_skill(requirement.skill_id, requirement.minimum_level) {
count = count + 1
}
None => ()
}
}
}
count
}
///|
fn check_coverage(
problem : PlanningProblem,
schedule : Schedule,
) -> ConstraintReport {
let mut report = ConstraintReport::empty()
for shift in problem.shifts {
let assigned = assignment_count_for_shift(schedule, shift.id)
if assigned < shift.required_workers {
report = report.add(
ConstraintViolation::new(
Understaffed,
shift.label + " is understaffed",
true,
)
.for_shift(shift.id)
.with_magnitude(shift.required_workers - assigned),
)
}
for requirement in shift.requirements {
let qualified = count_qualified_workers(
problem, schedule, shift, requirement,
)
if qualified < requirement.count {
report = report.add(
ConstraintViolation::new(
SkillShortage,
shift.label +
" lacks qualified workers for skill " +
requirement.skill_id,
true,
)
.for_shift(shift.id)
.with_magnitude(requirement.count - qualified),
)
}
}
}
report
}
///|
pub fn evaluate_constraints(
problem : PlanningProblem,
schedule : Schedule,
) -> ConstraintReport {
ConstraintReport::empty()
.append(check_assignment_references(problem, schedule))
.append(check_duplicates(schedule))
.append(check_worker_assignments(problem, schedule))
.append(check_coverage(problem, schedule))
}
///|
pub fn eligible_workers(
problem : PlanningProblem,
schedule : Schedule,
shift_id : String,
) -> Array[String] {
let result : Array[String] = []
for worker in problem.workers {
if check_candidate(problem, schedule, worker.id, shift_id).feasible() {
result.push(worker.id)
}
}
result
}