Skip to main content

problemreductions/models/misc/
staff_scheduling.rs

1//! Staff Scheduling problem implementation.
2//!
3//! Given a collection of schedule patterns, period staffing requirements, and a
4//! worker budget, determine whether workers can be assigned to schedules so that
5//! all requirements are met without exceeding the budget.
6
7use crate::registry::{CreateSpec, ProblemSchemaEntry};
8use crate::traits::Problem;
9use serde::{Deserialize, Serialize};
10
11inventory::submit! {
12    ProblemSchemaEntry {
13        name: "StaffScheduling",
14        display_name: "Staff Scheduling",
15        aliases: &[],
16        dimensions: &[],
17        category: crate::registry::ProblemCategory::Misc,
18        module_path: module_path!(),
19        description: "Assign workers to schedule patterns to satisfy per-period staffing requirements within a worker budget",
20        fields: StaffSchedulingCreateSpec::FIELDS,
21    }
22}
23
24/// The Staff Scheduling problem.
25///
26/// Each variable represents how many workers adopt a particular schedule
27/// pattern. A configuration is satisfying iff the total assigned workers does
28/// not exceed `num_workers` and every period's staffing requirement is met.
29#[derive(Debug, Clone, Serialize, Deserialize)]
30#[serde(try_from = "StaffSchedulingData")]
31pub struct StaffScheduling {
32    shifts_per_schedule: usize,
33    schedules: Vec<Vec<bool>>,
34    requirements: Vec<i64>,
35    num_workers: i64,
36}
37
38#[derive(Deserialize)]
39struct StaffSchedulingData {
40    shifts_per_schedule: usize,
41    schedules: Vec<Vec<bool>>,
42    requirements: Vec<i64>,
43    num_workers: i64,
44}
45
46impl TryFrom<StaffSchedulingData> for StaffScheduling {
47    type Error = crate::registry::ConstructionError;
48
49    fn try_from(data: StaffSchedulingData) -> Result<Self, Self::Error> {
50        Self::try_new(
51            data.shifts_per_schedule,
52            data.schedules,
53            data.requirements,
54            data.num_workers,
55        )
56    }
57}
58
59#[derive(Debug, Deserialize, crate::CreateSpec)]
60struct StaffSchedulingCreateSpec {
61    /// Required number of active periods in each schedule pattern.
62    k: usize,
63    /// Binary schedule patterns available to workers.
64    schedules: Vec<Vec<bool>>,
65    /// Minimum staffing requirement for each period.
66    requirements: Vec<i64>,
67    /// Maximum number of workers available.
68    num_workers: i64,
69}
70
71impl TryFrom<StaffSchedulingCreateSpec> for StaffScheduling {
72    type Error = crate::registry::ConstructionError;
73
74    fn try_from(spec: StaffSchedulingCreateSpec) -> Result<Self, Self::Error> {
75        Self::try_new(spec.k, spec.schedules, spec.requirements, spec.num_workers)
76    }
77}
78
79impl StaffScheduling {
80    /// Create a new Staff Scheduling instance.
81    ///
82    /// # Panics
83    ///
84    /// Panics if `num_workers` does not fit in `usize`, if any schedule has a
85    /// different number of periods than `requirements.len()`, or if any
86    /// schedule has a number of active periods different from
87    /// `shifts_per_schedule`.
88    pub fn new(
89        shifts_per_schedule: usize,
90        schedules: Vec<Vec<bool>>,
91        requirements: Vec<i64>,
92        num_workers: i64,
93    ) -> Self {
94        Self::try_new(shifts_per_schedule, schedules, requirements, num_workers)
95            .unwrap_or_else(|error| panic!("{error}"))
96    }
97
98    fn try_new(
99        shifts_per_schedule: usize,
100        schedules: Vec<Vec<bool>>,
101        requirements: Vec<i64>,
102        num_workers: i64,
103    ) -> Result<Self, crate::registry::ConstructionError> {
104        if usize::try_from(num_workers)
105            .ok()
106            .and_then(|workers| workers.checked_add(1))
107            .is_none()
108        {
109            return Err("num_workers must be nonnegative and encodable by dims()".into());
110        }
111
112        let num_periods = requirements.len();
113        for (index, schedule) in schedules.iter().enumerate() {
114            if schedule.len() != num_periods {
115                return Err(format!(
116                    "schedule {} has {} periods, expected {}",
117                    index,
118                    schedule.len(),
119                    num_periods
120                )
121                .into());
122            }
123            let ones = schedule.iter().filter(|&&active| active).count();
124            if ones != shifts_per_schedule {
125                return Err(format!(
126                    "schedule {} has {} active periods, expected {}",
127                    index, ones, shifts_per_schedule
128                )
129                .into());
130            }
131        }
132
133        Ok(Self {
134            shifts_per_schedule,
135            schedules,
136            requirements,
137            num_workers,
138        })
139    }
140
141    /// Get the number of periods.
142    pub fn num_periods(&self) -> usize {
143        self.requirements.len()
144    }
145
146    /// Get the required number of active periods per schedule.
147    pub fn shifts_per_schedule(&self) -> usize {
148        self.shifts_per_schedule
149    }
150
151    /// Get the schedule patterns.
152    pub fn schedules(&self) -> &[Vec<bool>] {
153        &self.schedules
154    }
155
156    /// Get the staffing requirements.
157    pub fn requirements(&self) -> &[i64] {
158        &self.requirements
159    }
160
161    /// Get the worker budget.
162    pub fn num_workers(&self) -> i64 {
163        self.num_workers
164    }
165
166    /// Get the number of schedule patterns.
167    pub fn num_schedules(&self) -> usize {
168        self.schedules.len()
169    }
170
171    fn worker_limit(&self) -> usize {
172        usize::try_from(self.num_workers)
173            .expect("validated nonnegative worker count must fit usize")
174    }
175
176    fn worker_counts_valid(&self, config: &[usize]) -> bool {
177        config.iter().all(|&count| count <= self.worker_limit())
178    }
179
180    fn within_budget(&self, config: &[usize]) -> Result<bool, crate::traits::EvaluationError> {
181        let total = config.iter().try_fold(0_i64, |total, &count| {
182            let count = i64::try_from(count).map_err(|_| {
183                crate::traits::EvaluationError::IntegerOverflow(
184                    "converting assigned worker count to i64".into(),
185                )
186            })?;
187            total.checked_add(count).ok_or_else(|| {
188                crate::traits::EvaluationError::IntegerOverflow(
189                    "summing assigned worker counts".into(),
190                )
191            })
192        })?;
193        Ok(total <= self.num_workers)
194    }
195
196    fn meets_requirements(&self, config: &[usize]) -> Result<bool, crate::traits::EvaluationError> {
197        let mut coverage = vec![0_i64; self.num_periods()];
198
199        for (count, schedule) in config.iter().zip(&self.schedules) {
200            if *count == 0 {
201                continue;
202            }
203            let count = i64::try_from(*count).map_err(|_| {
204                crate::traits::EvaluationError::IntegerOverflow(
205                    "converting scheduled worker count to i64".into(),
206                )
207            })?;
208            for (period, active) in schedule.iter().enumerate() {
209                if *active {
210                    coverage[period] = coverage[period].checked_add(count).ok_or_else(|| {
211                        crate::traits::EvaluationError::IntegerOverflow(
212                            "summing staffing coverage".into(),
213                        )
214                    })?;
215                }
216            }
217        }
218
219        Ok(coverage
220            .iter()
221            .zip(&self.requirements)
222            .all(|(covered, required)| covered >= required))
223    }
224}
225
226impl Problem for StaffScheduling {
227    const NAME: &'static str = "StaffScheduling";
228    type Solution = Vec<usize>;
229    type Value = crate::types::Or;
230
231    crate::problem_parameters![
232        ("num_periods", num_periods),
233        ("num_schedules", num_schedules),
234        ("num_workers", num_workers),
235    ];
236
237    fn evaluate(
238        &self,
239        config: &Self::Solution,
240    ) -> Result<crate::types::Or, crate::traits::EvaluationError> {
241        Ok({
242            crate::types::Or({
243                if config.len() != self.num_schedules() {
244                    return Err(crate::traits::EvaluationError::InvalidConfiguration(
245                        "staffing vector length does not match the schedules".into(),
246                    ));
247                }
248                self.worker_counts_valid(config)
249                    && self.within_budget(config)?
250                    && self.meets_requirements(config)?
251            })
252        })
253    }
254
255    fn variant() -> Vec<(&'static str, &'static str)> {
256        crate::variant_params![]
257    }
258}
259
260impl crate::solvers::BruteForceProblem for StaffScheduling {
261    fn dimensions(&self) -> Vec<usize> {
262        vec![self.worker_limit() + 1; self.num_schedules()]
263    }
264}
265
266crate::declare_variants! {
267    default StaffScheduling => "(num_workers + 1)^num_schedules" create StaffSchedulingCreateSpec,
268}
269
270crate::register_brute_force! {
271    StaffScheduling,
272}
273
274#[cfg(feature = "example-db")]
275pub(crate) fn canonical_model_example_specs() -> Vec<crate::example_db::specs::ModelExampleSpec> {
276    vec![crate::example_db::specs::ModelExampleSpec {
277        id: "staff_scheduling",
278        instance: Box::new(StaffScheduling::new(
279            5,
280            vec![
281                vec![true, true, true, true, true, false, false],
282                vec![false, true, true, true, true, true, false],
283                vec![false, false, true, true, true, true, true],
284                vec![true, false, false, true, true, true, true],
285                vec![true, true, false, false, true, true, true],
286            ],
287            vec![2, 2, 2, 3, 3, 2, 1],
288            4,
289        )),
290        optimal_config: serde_json::json!(vec![1, 1, 1, 1, 0]),
291        optimal_value: serde_json::json!(true),
292    }]
293}
294
295#[cfg(test)]
296#[path = "../../unit_tests/models/misc/staff_scheduling.rs"]
297mod tests;