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