problemreductions/models/misc/
staff_scheduling.rs1use 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#[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 k: usize,
41 schedules: Vec<Vec<bool>>,
43 requirements: Vec<i64>,
45 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 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 pub fn num_periods(&self) -> usize {
140 self.requirements.len()
141 }
142
143 pub fn shifts_per_schedule(&self) -> usize {
145 self.shifts_per_schedule
146 }
147
148 pub fn schedules(&self) -> &[Vec<bool>] {
150 &self.schedules
151 }
152
153 pub fn requirements(&self) -> &[i64] {
155 &self.requirements
156 }
157
158 pub fn num_workers(&self) -> i64 {
160 self.num_workers
161 }
162
163 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;