Separate user configs from resident workload bounds
This commit is contained in:
@@ -1,5 +1,3 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use chrono::Month;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
@@ -27,12 +25,6 @@ pub struct UserConfig {
|
||||
pub holidays: Vec<usize>,
|
||||
pub residents: Vec<Resident>,
|
||||
pub toxic_pairs: Vec<(String, String)>,
|
||||
|
||||
// calculated from inputs
|
||||
pub workload_limits: HashMap<String, u8>,
|
||||
pub holiday_limits: HashMap<String, u8>,
|
||||
pub shift_type_limits: HashMap<(String, ShiftType), u8>,
|
||||
pub shift_type_threshold: HashMap<(String, ShiftType), u8>,
|
||||
}
|
||||
|
||||
impl UserConfig {
|
||||
@@ -43,10 +35,6 @@ impl UserConfig {
|
||||
holidays: vec![],
|
||||
residents: vec![],
|
||||
toxic_pairs: vec![],
|
||||
workload_limits: HashMap::new(),
|
||||
holiday_limits: HashMap::new(),
|
||||
shift_type_limits: HashMap::new(),
|
||||
shift_type_threshold: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -92,160 +80,6 @@ impl UserConfig {
|
||||
|| self.holidays.contains(&(day.0 as usize))
|
||||
}
|
||||
|
||||
/// get map with total amount of slots in a month for each type of shift
|
||||
pub fn get_initial_supply(&self) -> HashMap<ShiftType, u8> {
|
||||
let mut supply = HashMap::new();
|
||||
let total_days = self.total_days();
|
||||
|
||||
for d in 1..=total_days {
|
||||
if Day(d).is_open_shift() {
|
||||
*supply.entry(ShiftType::OpenFirst).or_insert(0) += 1;
|
||||
*supply.entry(ShiftType::OpenSecond).or_insert(0) += 1;
|
||||
} else {
|
||||
*supply.entry(ShiftType::Closed).or_insert(0) += 1;
|
||||
}
|
||||
}
|
||||
supply
|
||||
}
|
||||
|
||||
/// this is called after the user config params have been initialized, can be done with the builder (lite) pattern
|
||||
/// initialize a hashmap for O(1) search calls for the residents' max workload
|
||||
pub fn calculate_workload_limits(&mut self) {
|
||||
let total_slots = self.total_slots();
|
||||
|
||||
let max_shifts_sum: usize = self
|
||||
.residents
|
||||
.iter()
|
||||
.map(|r| r.max_shifts.unwrap_or(0))
|
||||
.sum();
|
||||
|
||||
let residents_without_max_shifts: Vec<_> = self
|
||||
.residents
|
||||
.iter()
|
||||
.filter(|r| r.max_shifts.is_none())
|
||||
.collect();
|
||||
|
||||
let residents_without_max_shifts_size = residents_without_max_shifts.len();
|
||||
|
||||
if residents_without_max_shifts_size == 0 {
|
||||
for r in &self.residents {
|
||||
self.workload_limits
|
||||
.insert(r.id.clone(), r.max_shifts.unwrap_or(0) as u8);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Untested scenario: Resident has manual max_shifts and also reduced workload flag
|
||||
|
||||
let total_reduced_loads: usize = residents_without_max_shifts
|
||||
.iter()
|
||||
.filter(|r| r.reduced_load)
|
||||
.count();
|
||||
let max_shifts_ceiling = (total_slots - max_shifts_sum as u8 + total_reduced_loads as u8)
|
||||
.div_ceil(residents_without_max_shifts_size as u8);
|
||||
|
||||
for r in &self.residents {
|
||||
let max_shifts = if let Some(manual_max_shifts) = r.max_shifts {
|
||||
manual_max_shifts as u8
|
||||
} else if r.reduced_load {
|
||||
max_shifts_ceiling - 1
|
||||
} else {
|
||||
max_shifts_ceiling
|
||||
};
|
||||
self.workload_limits.insert(r.id.clone(), max_shifts);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn calculate_holiday_limits(&mut self) {
|
||||
let total_slots = self.total_slots();
|
||||
let total_holiday_slots = self.total_holiday_slots();
|
||||
|
||||
for r in &self.residents {
|
||||
let workload_limit = *self.workload_limits.get(&r.id).unwrap_or(&0);
|
||||
|
||||
let share = (workload_limit as f32 / total_slots as f32) * total_holiday_slots as f32;
|
||||
let holiday_limit = share.ceil() as u8;
|
||||
|
||||
self.holiday_limits.insert(r.id.clone(), holiday_limit);
|
||||
}
|
||||
}
|
||||
|
||||
/// shift type count fairness
|
||||
pub fn calculate_shift_type_fairness(&mut self) {
|
||||
let mut global_supply = self.get_initial_supply();
|
||||
let mut local_limits = HashMap::new();
|
||||
let mut local_thresholds = HashMap::new();
|
||||
|
||||
let all_shift_types = [
|
||||
ShiftType::OpenFirst,
|
||||
ShiftType::OpenSecond,
|
||||
ShiftType::Closed,
|
||||
];
|
||||
|
||||
// residents with 1 available shift types
|
||||
for res in self.residents.iter().filter(|r| r.allowed_types.len() == 1) {
|
||||
let stype = &res.allowed_types[0];
|
||||
let total_limit = *self.workload_limits.get(&res.id).unwrap_or(&0);
|
||||
|
||||
local_limits.insert((res.id.clone(), stype.clone()), total_limit);
|
||||
local_thresholds.insert((res.id.clone(), stype.clone()), total_limit - 1);
|
||||
|
||||
for other_type in &all_shift_types {
|
||||
if other_type != stype {
|
||||
local_limits.insert((res.id.clone(), other_type.clone()), 0);
|
||||
local_thresholds.insert((res.id.clone(), other_type.clone()), 0);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(s) = global_supply.get_mut(stype) {
|
||||
*s = s.saturating_sub(total_limit)
|
||||
}
|
||||
}
|
||||
|
||||
// residents with 2 available shift types
|
||||
for res in self.residents.iter().filter(|r| r.allowed_types.len() == 2) {
|
||||
let total_limit = *self.workload_limits.get(&res.id).unwrap_or(&0) as f32;
|
||||
let per_type = (total_limit / 2.0).ceil() as u8;
|
||||
|
||||
for stype in &all_shift_types {
|
||||
if res.allowed_types.contains(stype) {
|
||||
local_limits.insert((res.id.clone(), stype.clone()), per_type);
|
||||
local_thresholds.insert((res.id.clone(), stype.clone()), per_type - 1);
|
||||
if let Some(s) = global_supply.get_mut(stype) {
|
||||
*s = s.saturating_sub(per_type)
|
||||
}
|
||||
} else {
|
||||
local_limits.insert((res.id.clone(), stype.clone()), 0);
|
||||
local_thresholds.insert((res.id.clone(), stype.clone()), 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// residents with 3 available shift types
|
||||
let generalists: Vec<&Resident> = self
|
||||
.residents
|
||||
.iter()
|
||||
.filter(|r| r.allowed_types.len() == 3)
|
||||
.collect();
|
||||
|
||||
if !generalists.is_empty() {
|
||||
for stype in &all_shift_types {
|
||||
let remaining = *global_supply.get(stype).unwrap_or(&0);
|
||||
let fair_slice = (remaining as f32 / generalists.len() as f32)
|
||||
.ceil()
|
||||
.max(0.0) as u8;
|
||||
|
||||
for res in &generalists {
|
||||
local_limits.insert((res.id.clone(), stype.clone()), fair_slice);
|
||||
local_thresholds.insert((res.id.clone(), stype.clone()), fair_slice - 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
self.shift_type_limits = local_limits;
|
||||
self.shift_type_threshold = local_thresholds;
|
||||
}
|
||||
|
||||
/// Return all possible candidates for the next slot
|
||||
/// TODO: move this to another file, UserConfig should only hold the info set from GUI
|
||||
///
|
||||
@@ -301,10 +135,6 @@ impl UserConfig {
|
||||
holidays: vec![],
|
||||
residents: vec![],
|
||||
toxic_pairs: vec![],
|
||||
workload_limits: HashMap::new(),
|
||||
holiday_limits: HashMap::new(),
|
||||
shift_type_limits: HashMap::new(),
|
||||
shift_type_threshold: HashMap::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -317,19 +147,12 @@ impl From<UserConfigDTO> for UserConfig {
|
||||
holidays: value.holidays,
|
||||
residents: value.residents.into_iter().map(Resident::from).collect(),
|
||||
toxic_pairs: value.toxic_pairs,
|
||||
workload_limits: HashMap::new(),
|
||||
holiday_limits: HashMap::new(),
|
||||
shift_type_limits: HashMap::new(),
|
||||
shift_type_threshold: HashMap::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::collections::HashMap;
|
||||
|
||||
use chrono::Month;
|
||||
use rstest::{fixture, rstest};
|
||||
|
||||
use crate::{
|
||||
@@ -384,73 +207,6 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
fn test_set_limits_fair_distribution() {
|
||||
let mut config = UserConfig::default().with_residents(vec![
|
||||
Resident::new("1", "Stefanos").with_max_shifts(2),
|
||||
Resident::new("2", "Iordanis").with_max_shifts(2),
|
||||
Resident::new("3", "Maria").with_reduced_load(),
|
||||
Resident::new("4", "Veatriki"),
|
||||
Resident::new("5", "Takis"),
|
||||
]);
|
||||
|
||||
config.calculate_workload_limits();
|
||||
|
||||
assert_eq!(config.workload_limits["1"], 2);
|
||||
assert_eq!(config.workload_limits["2"], 2);
|
||||
assert_eq!(config.workload_limits["3"], 12);
|
||||
assert_eq!(config.workload_limits["4"], 13);
|
||||
assert_eq!(config.workload_limits["5"], 13);
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
fn test_set_limits_complex_distribution() {
|
||||
let mut config = UserConfig {
|
||||
month: Month::January,
|
||||
year: 2026,
|
||||
holidays: vec![],
|
||||
toxic_pairs: vec![],
|
||||
workload_limits: HashMap::new(),
|
||||
residents: vec![
|
||||
Resident::new("1", "Stefanos").with_max_shifts(2),
|
||||
Resident::new("2", "Iordanis").with_max_shifts(2),
|
||||
Resident::new("3", "Maria").with_reduced_load(),
|
||||
Resident::new("4", "Veatriki"),
|
||||
Resident::new("5", "Takis"),
|
||||
],
|
||||
holiday_limits: HashMap::new(),
|
||||
shift_type_limits: HashMap::new(),
|
||||
shift_type_threshold: HashMap::new(),
|
||||
};
|
||||
|
||||
config.calculate_workload_limits();
|
||||
|
||||
assert_eq!(config.workload_limits["1"], 2);
|
||||
assert_eq!(config.workload_limits["2"], 2);
|
||||
assert_eq!(config.workload_limits["3"], 14);
|
||||
assert_eq!(config.workload_limits["4"], 15);
|
||||
assert_eq!(config.workload_limits["5"], 15);
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
fn test_calculate_holiday_limits() {
|
||||
let mut config = UserConfig::default();
|
||||
|
||||
let stefanos = Resident::new("1", "Stefanos");
|
||||
let iordanis = Resident::new("2", "Iordanis");
|
||||
|
||||
config.residents = vec![stefanos, iordanis];
|
||||
|
||||
config.calculate_workload_limits();
|
||||
config.calculate_holiday_limits();
|
||||
|
||||
let stefanos_limit = *config.holiday_limits.get("1").unwrap();
|
||||
let iordanis_limit = *config.holiday_limits.get("2").unwrap();
|
||||
|
||||
assert_eq!(stefanos_limit, 6);
|
||||
assert_eq!(iordanis_limit, 6);
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
fn test_total_holiday_slots() {
|
||||
let config = UserConfig::default().with_holidays(vec![2, 3, 4]);
|
||||
|
||||
Reference in New Issue
Block a user