///|
/// A worker/day/shift roster template built on the generic solver.
pub struct RosterModel {
solver : Solver
assignments : Array[Array[Int]]
workers : Int
days : Int
shifts : Int
}
///|
/// Build an unconstrained roster with one shift variable per worker/day.
pub fn roster_model(workers : Int, days : Int, shifts : Int) -> RosterModel? {
if workers < 1 || days < 1 || shifts < 1 {
return None
}
let solver = new_solver()
let assignments : Array[Array[Int]] = []
for worker in 0.. Int {
self.workers
}
///|
/// Return the day count.
pub fn RosterModel::day_count(self : RosterModel) -> Int {
self.days
}
///|
/// Return the number of shifts.
pub fn RosterModel::shift_count(self : RosterModel) -> Int {
self.shifts
}
///|
/// Return a worker/day variable id.
pub fn RosterModel::variable(
self : RosterModel,
worker : Int,
day : Int,
) -> Int {
if worker < 0 || worker >= self.workers || day < 0 || day >= self.days {
abort("roster coordinate is outside the model")
}
self.assignments[worker][day]
}
///|
/// Return all variables for a worker.
pub fn RosterModel::worker_variables(
self : RosterModel,
worker : Int,
) -> Array[Int] {
if worker < 0 || worker >= self.workers {
abort("roster worker is outside the model")
}
self.assignments[worker].copy()
}
///|
/// Return all variables for a day.
pub fn RosterModel::day_variables(self : RosterModel, day : Int) -> Array[Int] {
if day < 0 || day >= self.days {
abort("roster day is outside the model")
}
self.assignments.map(row => row[day])
}
///|
/// Require exact worker coverage for each day and shift.
pub fn RosterModel::post_daily_cover(
self : RosterModel,
required : Array[Array[Int]],
) -> Unit {
if required.length() != self.days {
abort("roster coverage day count does not match model")
}
for day, row in required {
if row.length() != self.shifts {
abort("roster coverage shift count does not match model")
}
for shift, count in row {
self.solver.add_constraint(
count_value(self.day_variables(day), shift, count),
)
}
}
}
///|
/// Bound how often a worker may receive a shift type.
pub fn RosterModel::post_worker_shift_bounds(
self : RosterModel,
worker : Int,
shift : Int,
minimum : Int,
maximum : Int,
) -> Unit {
if shift < 0 || shift >= self.shifts || minimum > maximum {
abort("roster shift bounds are invalid")
}
let values = self.worker_variables(worker)
self.solver.add_constraint(at_least_value(values, shift, minimum))
self.solver.add_constraint(at_most_value(values, shift, maximum))
}
///|
/// Require adjacent assignments for one worker to use different shifts.
pub fn RosterModel::post_no_consecutive_shift(
self : RosterModel,
worker : Int,
) -> Unit {
let values = self.worker_variables(worker)
if values.length() < 2 {
return
}
for day in 0..<(self.days - 1) {
self.solver.add_constraint(not_equal(values[day], values[day + 1]))
}
}
///|
/// Require different shifts within a worker's rolling window.
pub fn RosterModel::post_worker_window_different(
self : RosterModel,
worker : Int,
width : Int,
) -> Unit {
if width < 1 || width > self.days {
abort("roster worker window is invalid")
}
let values = self.worker_variables(worker)
for start in 0..<(self.days - width + 1) {
self.solver.add_constraint(
all_different(roster_window(values, start, width)),
)
}
}
///|
/// Copy one worker/day window.
fn roster_window(values : Array[Int], start : Int, width : Int) -> Array[Int] {
let result : Array[Int] = []
for index in start..<(start + width) {
result.push(values[index])
}
result
}
///|
/// Prevent the same pair of workers from sharing a shift on a day.
pub fn RosterModel::post_pair_different(
self : RosterModel,
first : Int,
second : Int,
) -> Unit {
if first < 0 || second < 0 || first >= self.workers || second >= self.workers {
abort("roster worker pair is invalid")
}
if first == second {
abort("roster worker pair must contain two workers")
}
for day in 0.. Unit {
self.solver.add_constraint(all_different(self.day_variables(day)))
}
///|
/// Fix one worker/day assignment.
pub fn RosterModel::fix(
self : RosterModel,
worker : Int,
day : Int,
shift : Int,
) -> Bool {
if shift < 0 || shift >= self.shifts {
return false
}
self.solver.assign(self.variable(worker, day), shift)
}
///|
/// Bound a worker's number of assignments in a shift interval.
pub fn RosterModel::post_worker_interval(
self : RosterModel,
worker : Int,
lower_shift : Int,
upper_shift : Int,
minimum : Int,
maximum : Int,
) -> Unit {
if lower_shift > upper_shift || lower_shift < 0 || upper_shift >= self.shifts {
abort("roster shift interval is invalid")
}
let values = self.worker_variables(worker)
let allowed : Array[Int] = []
for shift in lower_shift..<=upper_shift {
allowed.push(shift)
}
let indicator : Array[Int] = []
for index, value in values {
let marker = self.solver.add_variable(
variable("worker_\{worker}_interval_\{index}", 0, 1),
)
let rows : Array[Array[Int]] = []
for candidate in 0.. (id, 1)), minimum),
)
self.solver.add_constraint(
linear_less_equal(indicator.map(id => (id, 1)), maximum),
)
}
///|
/// Configure the underlying search engine.
pub fn RosterModel::configure(
self : RosterModel,
config : SearchConfig,
) -> Unit {
self.solver.configure(config)
}
///|
/// Solve one roster.
pub fn RosterModel::solve(self : RosterModel) -> Solution? {
self.solver.solve()
}
///|
/// Enumerate rosters up to a limit.
pub fn RosterModel::solve_all(
self : RosterModel,
limit : Int,
) -> Array[Solution] {
self.solver.limit(limit)
self.solver.solve_all()
}
///|
/// Return the latest search statistics.
pub fn RosterModel::stats(self : RosterModel) -> SearchStats {
self.solver.stats()
}
///|
/// Check a solution against every roster constraint.
pub fn RosterModel::is_valid(self : RosterModel, solution : Solution) -> Bool {
self.solver.is_valid_solution(solution)
}
///|
/// Read one worker's assignments.
pub fn RosterModel::worker_values(
self : RosterModel,
solution : Solution,
worker : Int,
) -> Array[Int] {
self.worker_variables(worker).map(id => solution.get(id))
}
///|
/// Read all assignments in worker-major order.
pub fn RosterModel::values(
self : RosterModel,
solution : Solution,
) -> Array[Array[Int]] {
self.assignments.map(row => row.map(id => solution.get(id)))
}
///|
/// Count a shift for one worker.
pub fn RosterModel::shift_count_for(
self : RosterModel,
solution : Solution,
worker : Int,
shift : Int,
) -> Int {
let mut count = 0
for value in self.worker_values(solution, worker) {
if value == shift {
count += 1
}
}
count
}
///|
/// Return a day/shift coverage matrix.
pub fn RosterModel::coverage(
self : RosterModel,
solution : Solution,
) -> Array[Array[Int]] {
let result : Array[Array[Int]] = []
for day in 0.. solution.get(id))
for shift in 0.. Int {
let mut minimum = 2147483647
let mut maximum = -2147483647
for worker in 0.. maximum {
maximum = workload
}
}
maximum - minimum
}
///|
/// Render the roster as a stable worker-major table.
pub fn RosterModel::render(self : RosterModel, solution : Solution) -> String {
let builder = StringBuilder()
for worker in 0.. 0 {
builder.write_char('\n')
}
builder.write_string("worker \{worker}:")
for value in self.worker_values(solution, worker) {
builder.write_string(" \{value}")
}
}
builder.to_string()
}
///|
/// A compact roster quality score.
pub struct RosterScore {
spread : Int
violations : Int
coverage_total : Int
}
///|
/// Score a valid or partial roster using observable structural counters.
pub fn RosterModel::score(
self : RosterModel,
solution : Solution,
) -> RosterScore {
let coverage = self.coverage(solution)
let mut total = 0
for row in coverage {
for count in row {
total += count
}
}
{
spread: self.workload_spread(solution),
violations: self.solver.violations(solution).length(),
coverage_total: total,
}
}
///|
/// Return the worker workload spread.
pub fn RosterScore::spread(self : RosterScore) -> Int {
self.spread
}
///|
/// Return the number of detected violations.
pub fn RosterScore::violations(self : RosterScore) -> Int {
self.violations
}
///|
/// Return the number of assigned worker/day cells represented by coverage.
pub fn RosterScore::coverage_total(self : RosterScore) -> Int {
self.coverage_total
}
///|
/// Render a roster score.
pub fn RosterScore::describe(self : RosterScore) -> String {
"spread=\{self.spread}, violations=\{self.violations}, coverage=\{self.coverage_total}"
}
///|
/// Build a small rotating-shift roster template.
pub fn rotating_roster(workers : Int, days : Int) -> RosterModel? {
match roster_model(workers, days, workers) {
Some(model) => {
for day in 0.. None
}
}
///|
/// Build a two-shift roster with a minimum number of workers on each shift.
pub fn two_shift_roster(
workers : Int,
days : Int,
minimum_per_shift : Int,
) -> RosterModel? {
match roster_model(workers, days, 2) {
Some(model) => {
if minimum_per_shift < 0 || minimum_per_shift * 2 > workers {
return None
}
for day in 0.. None
}
}
///|
/// Return a stable roster fingerprint.
pub fn RosterModel::fingerprint(
self : RosterModel,
solution : Solution,
) -> String {
let builder = StringBuilder()
for worker, row in self.values(solution) {
if worker > 0 {
builder.write_char(';')
}
for day, value in row {
if day > 0 {
builder.write_char(',')
}
builder.write_string("\{value}")
}
}
builder.to_string()
}