Separate user configs from resident workload bounds

This commit is contained in:
2026-01-14 00:07:20 +02:00
parent 8865f0b95a
commit d7d4b109c3
7 changed files with 331 additions and 303 deletions

232
src-tauri/src/bounds.rs Normal file
View File

@@ -0,0 +1,232 @@
use std::collections::HashMap;
use crate::{config::UserConfig, resident::Resident, schedule::ShiftType, slot::Day};
pub struct WorkloadBounds {
pub max_workloads: HashMap<String, u8>,
pub max_holiday_shifts: HashMap<String, u8>,
pub max_by_shift_type: HashMap<(String, ShiftType), u8>,
pub min_by_shift_type: HashMap<(String, ShiftType), u8>,
}
impl WorkloadBounds {
pub fn new() -> Self {
Self {
max_workloads: HashMap::new(),
max_holiday_shifts: HashMap::new(),
max_by_shift_type: HashMap::new(),
min_by_shift_type: HashMap::new(),
}
}
pub fn new_with_config(config: &UserConfig) -> Self {
let mut bounds = Self::new();
bounds.calculate_max_workloads(config);
bounds.calculate_max_holiday_shifts(config);
bounds.calculate_max_by_shift_type(config);
bounds
}
/// get map with total amount of slots in a month for each type of shift
pub fn get_initial_supply(&self, config: &UserConfig) -> HashMap<ShiftType, u8> {
let mut supply = HashMap::new();
let total_days = config.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_max_workloads(&mut self, config: &UserConfig) {
let total_slots = config.total_slots();
let max_shifts_sum: usize = config
.residents
.iter()
.map(|r| r.max_shifts.unwrap_or(0))
.sum();
let residents_without_max_shifts: Vec<_> = config
.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 &config.residents {
self.max_workloads
.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 &config.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.max_workloads.insert(r.id.clone(), max_shifts);
}
}
///
pub fn calculate_max_holiday_shifts(&mut self, config: &UserConfig) {
let total_slots = config.total_slots();
let total_holiday_slots = config.total_holiday_slots();
for r in &config.residents {
let workload_limit = *self.max_workloads.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.max_holiday_shifts.insert(r.id.clone(), holiday_limit);
}
}
///
pub fn calculate_max_by_shift_type(&mut self, config: &UserConfig) {
let mut global_supply = self.get_initial_supply(config);
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 config
.residents
.iter()
.filter(|r| r.allowed_types.len() == 1)
{
let stype = &res.allowed_types[0];
let total_limit = *self.max_workloads.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 config
.residents
.iter()
.filter(|r| r.allowed_types.len() == 2)
{
let total_limit = *self.max_workloads.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> = config
.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.max_by_shift_type = local_limits;
self.min_by_shift_type = local_thresholds;
}
}
#[cfg(test)]
mod tests {
use rstest::{fixture, rstest};
use crate::{bounds::WorkloadBounds, config::UserConfig, resident::Resident};
#[fixture]
fn config() -> UserConfig {
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"),
])
}
#[rstest]
fn test_max_workloads(config: UserConfig) {
let bounds = WorkloadBounds::new_with_config(&config);
assert_eq!(bounds.max_workloads["1"], 2);
assert_eq!(bounds.max_workloads["2"], 2);
assert_eq!(bounds.max_workloads["3"], 12);
assert_eq!(bounds.max_workloads["4"], 13);
assert_eq!(bounds.max_workloads["5"], 13);
}
#[rstest]
fn test_calculate_max_holiday_shifts(config: UserConfig) {
let bounds = WorkloadBounds::new_with_config(&config);
let stefanos_limit = *bounds.max_holiday_shifts.get("1").unwrap();
let iordanis_limit = *bounds.max_holiday_shifts.get("2").unwrap();
assert_eq!(stefanos_limit, 1);
assert_eq!(iordanis_limit, 1);
}
}

View File

@@ -1,5 +1,3 @@
use std::collections::HashMap;
use chrono::Month; use chrono::Month;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
@@ -27,12 +25,6 @@ pub struct UserConfig {
pub holidays: Vec<usize>, pub holidays: Vec<usize>,
pub residents: Vec<Resident>, pub residents: Vec<Resident>,
pub toxic_pairs: Vec<(String, String)>, 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 { impl UserConfig {
@@ -43,10 +35,6 @@ impl UserConfig {
holidays: vec![], holidays: vec![],
residents: vec![], residents: vec![],
toxic_pairs: 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)) || 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 /// Return all possible candidates for the next slot
/// TODO: move this to another file, UserConfig should only hold the info set from GUI /// TODO: move this to another file, UserConfig should only hold the info set from GUI
/// ///
@@ -301,10 +135,6 @@ impl UserConfig {
holidays: vec![], holidays: vec![],
residents: vec![], residents: vec![],
toxic_pairs: 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, holidays: value.holidays,
residents: value.residents.into_iter().map(Resident::from).collect(), residents: value.residents.into_iter().map(Resident::from).collect(),
toxic_pairs: value.toxic_pairs, 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)] #[cfg(test)]
mod tests { mod tests {
use std::collections::HashMap;
use chrono::Month;
use rstest::{fixture, rstest}; use rstest::{fixture, rstest};
use crate::{ 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] #[rstest]
fn test_total_holiday_slots() { fn test_total_holiday_slots() {
let config = UserConfig::default().with_holidays(vec![2, 3, 4]); let config = UserConfig::default().with_holidays(vec![2, 3, 4]);

View File

@@ -164,8 +164,8 @@ mod tests {
use rstest::{fixture, rstest}; use rstest::{fixture, rstest};
use crate::{ use crate::{
config::UserConfig, generator::backtracking, resident::Resident, schedule::MonthlySchedule, bounds::WorkloadBounds, config::UserConfig, generator::backtracking, resident::Resident,
slot::Slot, schedule::MonthlySchedule, slot::Slot,
}; };
#[fixture] #[fixture]
@@ -175,23 +175,28 @@ mod tests {
#[fixture] #[fixture]
fn config() -> UserConfig { fn config() -> UserConfig {
let mut config = UserConfig::default().with_residents(vec![ UserConfig::default().with_residents(vec![
Resident::new("1", "Στέφανος"), Resident::new("1", "Στέφανος"),
Resident::new("2", "Ιορδάνης"), Resident::new("2", "Ιορδάνης"),
Resident::new("3", "Μαρία"), Resident::new("3", "Μαρία"),
Resident::new("4", "Βεατρίκη"), Resident::new("4", "Βεατρίκη"),
Resident::new("5", "Τάκης"), Resident::new("5", "Τάκης"),
Resident::new("6", "Μάκης"), Resident::new("6", "Μάκης"),
]); ])
config.calculate_workload_limits(); }
config.calculate_holiday_limits();
config.calculate_shift_type_fairness(); #[fixture]
config fn bounds(config: UserConfig) -> WorkloadBounds {
WorkloadBounds::new_with_config(&config)
} }
#[rstest] #[rstest]
pub fn test_export_as_doc(mut schedule: MonthlySchedule, config: UserConfig) { pub fn test_export_as_doc(
backtracking(&mut schedule, Slot::default(), &config); mut schedule: MonthlySchedule,
config: UserConfig,
bounds: WorkloadBounds,
) {
backtracking(&mut schedule, Slot::default(), &config, &bounds);
schedule.export_as_doc(&config); schedule.export_as_doc(&config);
} }
} }

View File

@@ -1,18 +1,23 @@
use rand::Rng; use rand::Rng;
use crate::{config::UserConfig, schedule::MonthlySchedule, slot::Slot}; use crate::{bounds::WorkloadBounds, config::UserConfig, schedule::MonthlySchedule, slot::Slot};
/// DFS where maximum depth is calculated by total_days_of_month + odd_days_of_month each node is called a slot /// DFS where maximum depth is calculated by total_days_of_month + odd_days_of_month each node is called a slot
/// Starts with schedule partially completed from the user interface /// Starts with schedule partially completed from the user interface
/// Ends with a full schedule following restrictions and fairness /// Ends with a full schedule following restrictions and fairness
pub fn backtracking(schedule: &mut MonthlySchedule, slot: Slot, config: &UserConfig) -> bool { pub fn backtracking(
if !slot.is_first() && schedule.restrictions_violated(&slot.previous(), config) { schedule: &mut MonthlySchedule,
slot: Slot,
config: &UserConfig,
bounds: &WorkloadBounds,
) -> bool {
if !slot.is_first() && schedule.restrictions_violated(&slot.previous(), config, bounds) {
log::trace!("Cutting branch due to restriction violation"); log::trace!("Cutting branch due to restriction violation");
return false; return false;
} }
if slot.greater_than(config.total_days()) { if slot.greater_than(config.total_days()) {
if !schedule.is_per_shift_threshold_met(config) { if !schedule.is_per_shift_threshold_met(config, bounds) {
return false; return false;
} }
@@ -21,7 +26,7 @@ pub fn backtracking(schedule: &mut MonthlySchedule, slot: Slot, config: &UserCon
} }
if schedule.is_slot_manually_assigned(&slot) { if schedule.is_slot_manually_assigned(&slot) {
return backtracking(schedule, slot.next(), config); return backtracking(schedule, slot.next(), config, bounds);
} }
// sort candidates by current workload, add rng for tie breakers // sort candidates by current workload, add rng for tie breakers
@@ -35,7 +40,7 @@ pub fn backtracking(schedule: &mut MonthlySchedule, slot: Slot, config: &UserCon
for (resident, _) in candidates { for (resident, _) in candidates {
schedule.insert(slot, resident); schedule.insert(slot, resident);
if backtracking(schedule, slot.next(), config) { if backtracking(schedule, slot.next(), config, bounds) {
log::trace!("Solution found, exiting recursive algorithm"); log::trace!("Solution found, exiting recursive algorithm");
return true; return true;
} }
@@ -51,6 +56,7 @@ mod tests {
use rstest::{fixture, rstest}; use rstest::{fixture, rstest};
use crate::{ use crate::{
bounds::WorkloadBounds,
config::UserConfig, config::UserConfig,
generator::backtracking, generator::backtracking,
resident::Resident, resident::Resident,
@@ -65,23 +71,33 @@ mod tests {
#[fixture] #[fixture]
fn config() -> UserConfig { fn config() -> UserConfig {
let mut config = UserConfig::default().with_residents(vec![ UserConfig::default().with_residents(vec![
Resident::new("1", "Stefanos"), Resident::new("1", "Stefanos"),
Resident::new("2", "Iordanis"), Resident::new("2", "Iordanis"),
Resident::new("3", "Maria"), Resident::new("3", "Maria"),
Resident::new("4", "Veatriki"), Resident::new("4", "Veatriki"),
Resident::new("5", "Takis"), Resident::new("5", "Takis"),
Resident::new("6", "Akis"), Resident::new("6", "Akis"),
]); ])
config.calculate_workload_limits(); }
config.calculate_holiday_limits();
config.calculate_shift_type_fairness(); #[fixture]
config fn bounds(config: UserConfig) -> WorkloadBounds {
WorkloadBounds::new_with_config(&config)
} }
#[rstest] #[rstest]
fn test_backtracking(mut schedule: MonthlySchedule, config: UserConfig) { fn test_backtracking(
assert!(backtracking(&mut schedule, Slot::default(), &config)); mut schedule: MonthlySchedule,
config: UserConfig,
bounds: WorkloadBounds,
) {
assert!(backtracking(
&mut schedule,
Slot::default(),
&config,
&bounds
));
for d in 1..=config.total_days() { for d in 1..=config.total_days() {
let day = Day(d); let day = Day(d);
@@ -98,7 +114,7 @@ mod tests {
for r in &config.residents { for r in &config.residents {
let workload = schedule.current_workload(r); let workload = schedule.current_workload(r);
let limit = *config.workload_limits.get(&r.id).unwrap(); let limit = *bounds.max_workloads.get(&r.id).unwrap();
assert!(workload <= limit as usize); assert!(workload <= limit as usize);
} }

View File

@@ -3,6 +3,7 @@ use std::sync::Mutex;
use log::info; use log::info;
use crate::{ use crate::{
bounds::WorkloadBounds,
config::{UserConfig, UserConfigDTO}, config::{UserConfig, UserConfigDTO},
export::{Export, FileType}, export::{Export, FileType},
generator::backtracking, generator::backtracking,
@@ -10,10 +11,10 @@ use crate::{
slot::Slot, slot::Slot,
}; };
mod bounds;
mod config;
mod export; mod export;
mod generator; mod generator;
mod config;
mod resident; mod resident;
mod schedule; mod schedule;
mod slot; mod slot;
@@ -28,16 +29,14 @@ struct AppState {
#[tauri::command] #[tauri::command]
fn generate(config: UserConfigDTO, state: tauri::State<'_, AppState>) -> MonthlySchedule { fn generate(config: UserConfigDTO, state: tauri::State<'_, AppState>) -> MonthlySchedule {
info!("{:?}", config); info!("{:?}", config);
let mut config = UserConfig::from(config); let config = UserConfig::from(config);
config.calculate_workload_limits(); let bounds = WorkloadBounds::new_with_config(&config);
config.calculate_holiday_limits();
config.calculate_shift_type_fairness();
info!("{:?}", config); info!("{:?}", config);
let mut schedule = MonthlySchedule::new(); let mut schedule = MonthlySchedule::new();
schedule.prefill(&config); schedule.prefill(&config);
info!("{}", schedule.pretty_print(&config)); info!("{}", schedule.pretty_print(&config));
backtracking(&mut schedule, Slot::default(), &config); backtracking(&mut schedule, Slot::default(), &config, &bounds);
let mut internal_schedule = state.schedule.lock().unwrap(); let mut internal_schedule = state.schedule.lock().unwrap();
*internal_schedule = schedule.clone(); *internal_schedule = schedule.clone();
info!("{}", schedule.pretty_print(&config)); info!("{}", schedule.pretty_print(&config));

View File

@@ -98,4 +98,4 @@ impl From<ResidentDTO> for Resident {
reduced_load: value.reduced_load, reduced_load: value.reduced_load,
} }
} }
} }

View File

@@ -2,6 +2,7 @@ use serde::{ser::SerializeMap, Deserialize, Serialize};
use std::collections::HashMap; use std::collections::HashMap;
use crate::{ use crate::{
bounds::WorkloadBounds,
config::UserConfig, config::UserConfig,
resident::Resident, resident::Resident,
slot::{weekday_to_greek, Day, ShiftPosition, Slot}, slot::{weekday_to_greek, Day, ShiftPosition, Slot},
@@ -73,7 +74,7 @@ impl MonthlySchedule {
.count() .count()
} }
pub fn is_per_shift_threshold_met(&self, config: &UserConfig) -> bool { pub fn is_per_shift_threshold_met(&self, config: &UserConfig, bounds: &WorkloadBounds) -> bool {
for res in &config.residents { for res in &config.residents {
for stype in [ for stype in [
ShiftType::OpenFirst, ShiftType::OpenFirst,
@@ -81,8 +82,7 @@ impl MonthlySchedule {
ShiftType::Closed, ShiftType::Closed,
] { ] {
let count = self.count_shifts(&res.id, Some(stype.clone())); let count = self.count_shifts(&res.id, Some(stype.clone()));
if let Some(&threshold) = config.shift_type_threshold.get(&(res.id.clone(), stype)) if let Some(&threshold) = bounds.min_by_shift_type.get(&(res.id.clone(), stype)) {
{
if count < threshold as usize { if count < threshold as usize {
return false; return false;
} }
@@ -111,12 +111,17 @@ impl MonthlySchedule {
/// ///
/// @slot points to an occupied slot /// @slot points to an occupied slot
/// @config info manually set on the GUI by the user /// @config info manually set on the GUI by the user
pub fn restrictions_violated(&self, slot: &Slot, config: &UserConfig) -> bool { pub fn restrictions_violated(
&self,
slot: &Slot,
config: &UserConfig,
bounds: &WorkloadBounds,
) -> bool {
self.same_resident_in_consecutive_days(slot) self.same_resident_in_consecutive_days(slot)
|| self.has_toxic_pair(slot, config) || self.has_toxic_pair(slot, config)
|| self.is_workload_unbalanced(slot, config) || self.is_workload_unbalanced(slot, config, bounds)
|| self.is_holiday_workload_imbalanced(slot, config) || self.is_holiday_workload_imbalanced(slot, config, bounds)
|| self.is_shift_type_distribution_unfair(slot, config) || self.is_shift_type_distribution_unfair(slot, bounds)
} }
/// same_resident_in_consecutive_days /// same_resident_in_consecutive_days
@@ -160,7 +165,12 @@ impl MonthlySchedule {
} }
/// is_workload_unbalanced /// is_workload_unbalanced
pub fn is_workload_unbalanced(&self, slot: &Slot, config: &UserConfig) -> bool { pub fn is_workload_unbalanced(
&self,
slot: &Slot,
config: &UserConfig,
bounds: &WorkloadBounds,
) -> bool {
let res_id = match self.get_resident_id(slot) { let res_id = match self.get_resident_id(slot) {
Some(id) => id, Some(id) => id,
None => return false, None => return false,
@@ -169,7 +179,7 @@ impl MonthlySchedule {
if let Some(resident) = config.residents.iter().find(|r| &r.id == res_id) { if let Some(resident) = config.residents.iter().find(|r| &r.id == res_id) {
let current_workload = self.current_workload(resident); let current_workload = self.current_workload(resident);
if let Some(&limit) = config.workload_limits.get(res_id) { if let Some(&limit) = bounds.max_workloads.get(res_id) {
let mut workload_limit = limit; let mut workload_limit = limit;
if resident.reduced_load { if resident.reduced_load {
workload_limit -= 1; workload_limit -= 1;
@@ -185,7 +195,12 @@ impl MonthlySchedule {
} }
/// is_holiday_workload_imbalanced /// is_holiday_workload_imbalanced
pub fn is_holiday_workload_imbalanced(&self, slot: &Slot, config: &UserConfig) -> bool { pub fn is_holiday_workload_imbalanced(
&self,
slot: &Slot,
config: &UserConfig,
bounds: &WorkloadBounds,
) -> bool {
if !config.is_holiday_or_weekend_slot(slot.day.0) { if !config.is_holiday_or_weekend_slot(slot.day.0) {
return false; return false;
} }
@@ -198,7 +213,7 @@ impl MonthlySchedule {
if let Some(resident) = config.residents.iter().find(|r| &r.id == res_id) { if let Some(resident) = config.residents.iter().find(|r| &r.id == res_id) {
let current_holiday_workload = self.current_holiday_workload(resident, config); let current_holiday_workload = self.current_holiday_workload(resident, config);
if let Some(&holiday_limit) = config.holiday_limits.get(res_id) { if let Some(&holiday_limit) = bounds.max_holiday_shifts.get(res_id) {
if current_holiday_workload > holiday_limit as usize { if current_holiday_workload > holiday_limit as usize {
return true; return true;
} }
@@ -209,7 +224,7 @@ impl MonthlySchedule {
} }
/// is_shift_type_distribution_unfair /// is_shift_type_distribution_unfair
pub fn is_shift_type_distribution_unfair(&self, slot: &Slot, config: &UserConfig) -> bool { pub fn is_shift_type_distribution_unfair(&self, slot: &Slot, bounds: &WorkloadBounds) -> bool {
let resident_id = match self.get_resident_id(slot) { let resident_id = match self.get_resident_id(slot) {
Some(id) => id, Some(id) => id,
None => return false, None => return false,
@@ -226,8 +241,8 @@ impl MonthlySchedule {
let current_count = self.count_shifts(resident_id, Some(current_shift_type.clone())); let current_count = self.count_shifts(resident_id, Some(current_shift_type.clone()));
if let Some(&limit) = config if let Some(&limit) = bounds
.shift_type_limits .max_by_shift_type
.get(&(resident_id.clone(), current_shift_type.clone())) .get(&(resident_id.clone(), current_shift_type.clone()))
{ {
return current_count > limit as usize; return current_count > limit as usize;
@@ -325,6 +340,7 @@ mod tests {
use rstest::{fixture, rstest}; use rstest::{fixture, rstest};
use crate::{ use crate::{
bounds::WorkloadBounds,
config::UserConfig, config::UserConfig,
resident::Resident, resident::Resident,
schedule::{Day, MonthlySchedule, Slot}, schedule::{Day, MonthlySchedule, Slot},
@@ -413,7 +429,7 @@ mod tests {
} }
#[rstest] #[rstest]
fn test_is_workload_unbalanced(mut schedule: MonthlySchedule, mut config: UserConfig) { fn test_is_workload_unbalanced(mut schedule: MonthlySchedule, config: UserConfig) {
let slot_1 = Slot::new(Day(1), ShiftPosition::First); let slot_1 = Slot::new(Day(1), ShiftPosition::First);
let slot_2 = Slot::new(Day(1), ShiftPosition::Second); let slot_2 = Slot::new(Day(1), ShiftPosition::Second);
let slot_3 = Slot::new(Day(2), ShiftPosition::First); let slot_3 = Slot::new(Day(2), ShiftPosition::First);
@@ -421,21 +437,23 @@ mod tests {
let stefanos = &config.residents[0]; let stefanos = &config.residents[0];
let iordanis = &config.residents[1]; let iordanis = &config.residents[1];
config.workload_limits.insert("1".to_string(), 1); let mut bounds = WorkloadBounds::new();
config.workload_limits.insert("2".to_string(), 2);
bounds.max_workloads.insert("1".to_string(), 1);
bounds.max_workloads.insert("2".to_string(), 2);
schedule.insert(slot_1, &stefanos); schedule.insert(slot_1, &stefanos);
assert!(!schedule.is_workload_unbalanced(&slot_1, &config)); assert!(!schedule.is_workload_unbalanced(&slot_1, &config, &bounds));
schedule.insert(slot_2, &iordanis); schedule.insert(slot_2, &iordanis);
assert!(!schedule.is_workload_unbalanced(&slot_2, &config)); assert!(!schedule.is_workload_unbalanced(&slot_2, &config, &bounds));
schedule.insert(slot_3, &stefanos); schedule.insert(slot_3, &stefanos);
assert!(schedule.is_workload_unbalanced(&slot_3, &config)); assert!(schedule.is_workload_unbalanced(&slot_3, &config, &bounds));
} }
#[rstest] #[rstest]
fn test_is_holiday_workload_imbalanced(mut schedule: MonthlySchedule, mut config: UserConfig) { fn test_is_holiday_workload_imbalanced(mut schedule: MonthlySchedule, config: UserConfig) {
let slot_1 = Slot::new(Day(1), ShiftPosition::First); let slot_1 = Slot::new(Day(1), ShiftPosition::First);
let slot_2 = Slot::new(Day(1), ShiftPosition::Second); let slot_2 = Slot::new(Day(1), ShiftPosition::Second);
let slot_7 = Slot::new(Day(7), ShiftPosition::First); let slot_7 = Slot::new(Day(7), ShiftPosition::First);
@@ -443,16 +461,18 @@ mod tests {
let stefanos = &config.residents[0]; let stefanos = &config.residents[0];
let iordanis = &config.residents[1]; let iordanis = &config.residents[1];
config.holiday_limits.insert("1".to_string(), 1); let mut bounds = WorkloadBounds::new();
config.holiday_limits.insert("2".to_string(), 1);
bounds.max_holiday_shifts.insert("1".to_string(), 1);
bounds.max_holiday_shifts.insert("2".to_string(), 1);
schedule.insert(slot_1, &stefanos); schedule.insert(slot_1, &stefanos);
assert!(!schedule.is_holiday_workload_imbalanced(&slot_1, &config)); assert!(!schedule.is_holiday_workload_imbalanced(&slot_1, &config, &bounds));
schedule.insert(slot_2, &iordanis); schedule.insert(slot_2, &iordanis);
assert!(!schedule.is_holiday_workload_imbalanced(&slot_2, &config)); assert!(!schedule.is_holiday_workload_imbalanced(&slot_2, &config, &bounds));
schedule.insert(slot_7, &stefanos); schedule.insert(slot_7, &stefanos);
assert!(schedule.is_holiday_workload_imbalanced(&slot_7, &config)); assert!(schedule.is_holiday_workload_imbalanced(&slot_7, &config, &bounds));
} }
} }