Use maps to track workload progress instead of recalculating them at every step of the search, calculate total days/slots once, add integration tests, add log folder

This commit is contained in:
2026-01-17 18:41:43 +02:00
parent 908f114e54
commit 5bad63e8a7
10 changed files with 819 additions and 574 deletions

View File

@@ -1,6 +1,9 @@
use crate::{
bounds::WorkloadBounds, config::UserConfig, resident::ResidentId, schedule::MonthlySchedule,
config::UserConfig,
resident::ResidentId,
schedule::MonthlySchedule,
slot::Slot,
workload::{WorkloadBounds, WorkloadTracker},
};
use rand::Rng;
@@ -15,52 +18,62 @@ impl Scheduler {
Self { config, bounds }
}
pub fn run(&self, schedule: &mut MonthlySchedule) -> bool {
pub fn new_with_config(config: UserConfig) -> Self {
let bounds = WorkloadBounds::new_with_config(&config);
Self { config, bounds }
}
pub fn run(&self, schedule: &mut MonthlySchedule, tracker: &mut WorkloadTracker) -> bool {
schedule.prefill(&self.config);
self.search(schedule, Slot::default())
for (slot, res_id) in schedule.0.iter() {
tracker.insert(res_id, &self.config, *slot);
}
self.search(schedule, tracker, Slot::default())
}
/// 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
/// Ends with a full schedule following restrictions and fairness
pub fn search(&self, schedule: &mut MonthlySchedule, slot: Slot) -> bool {
pub fn search(
&self,
schedule: &mut MonthlySchedule,
tracker: &mut WorkloadTracker,
slot: Slot,
) -> bool {
if !slot.is_first()
&& schedule.restrictions_violated(&slot.previous(), &self.config, &self.bounds)
&& schedule.restrictions_violated(&slot.previous(), &self.config, &self.bounds, tracker)
{
log::trace!("Cutting branch due to restriction violation");
return false;
}
if slot.greater_than(self.config.total_days()) {
if !schedule.is_per_shift_threshold_met(&self.config, &self.bounds) {
return false;
}
log::trace!("Solution found, exiting recursive algorithm");
return true;
if slot.greater_than(self.config.total_days) {
return tracker.are_all_thresholds_met(&self.config, &self.bounds);
}
if schedule.is_slot_manually_assigned(&slot) {
return self.search(schedule, slot.next());
return self.search(schedule, tracker, slot.next());
}
// sort candidates by current workload, add rng for tie breakers
let mut valid_resident_ids = self.valid_residents(slot, schedule);
valid_resident_ids.sort_unstable_by_key(|res_id| {
let workload = schedule.current_workload(res_id);
let workload = tracker.current_workload(res_id);
let tie_breaker: f64 = rand::rng().random();
(workload, (tie_breaker * 1000.0) as usize)
});
for id in &valid_resident_ids {
schedule.insert(slot, id);
tracker.insert(id, &self.config, slot);
if self.search(schedule, slot.next()) {
log::trace!("Solution found, exiting recursive algorithm");
if self.search(schedule, tracker, slot.next()) {
return true;
}
schedule.remove(slot);
tracker.remove(id, &self.config, slot);
}
false
@@ -68,15 +81,19 @@ impl Scheduler {
/// Return all valid residents for the current slot
pub fn valid_residents(&self, slot: Slot, schedule: &MonthlySchedule) -> Vec<&ResidentId> {
let required_type = slot.shift_type(); // Calculate once here
let other_resident = schedule.get_resident_id(&slot.other_position());
let required_type = slot.shift_type();
let other_resident = slot
.other_position()
.and_then(|partner_slot| schedule.get_resident_id(&partner_slot));
self.config
.residents
.iter()
.filter(|r| Some(&r.id) != other_resident)
.filter(|r| !r.negative_shifts.contains(&slot.day))
.filter(|r| r.allowed_types.contains(&required_type))
.filter(|r| {
Some(&r.id) != other_resident
&& !r.negative_shifts.contains(&slot.day)
&& r.allowed_types.contains(&required_type)
})
.map(|r| &r.id)
.collect()
}
@@ -87,12 +104,12 @@ mod tests {
use rstest::{fixture, rstest};
use crate::{
bounds::WorkloadBounds,
config::UserConfig,
resident::Resident,
schedule::MonthlySchedule,
scheduler::Scheduler,
slot::{Day, ShiftPosition, Slot},
workload::{WorkloadBounds, WorkloadTracker},
};
#[fixture]
@@ -122,11 +139,20 @@ mod tests {
Scheduler::new(config, bounds)
}
#[rstest]
fn test_search(mut schedule: MonthlySchedule, scheduler: Scheduler) {
assert!(scheduler.search(&mut schedule, Slot::default()));
#[fixture]
fn tracker() -> WorkloadTracker {
WorkloadTracker::default()
}
for d in 1..=scheduler.config.total_days() {
#[rstest]
fn test_search(
mut schedule: MonthlySchedule,
mut tracker: WorkloadTracker,
scheduler: Scheduler,
) {
assert!(scheduler.run(&mut schedule, &mut tracker));
for d in 1..=scheduler.config.total_days {
let day = Day(d);
if day.is_open_shift() {
let slot_first = Slot::new(day, ShiftPosition::First);
@@ -140,9 +166,9 @@ mod tests {
}
for r in &scheduler.config.residents {
let workload = schedule.current_workload(&r.id);
let workload = tracker.current_workload(&r.id);
let limit = *scheduler.bounds.max_workloads.get(&r.id).unwrap();
assert!(workload <= limit as usize);
assert!(workload <= limit);
}
println!("{}", schedule.pretty_print(&scheduler.config));