Spaces:
Running
Running
| use crate::domain::{Lesson, Plan, Timeslot, Weekday}; | |
| use chrono::NaiveTime; | |
| use solverforge::prelude::*; | |
| use solverforge::IncrementalConstraint; | |
| /// HARD: No two lessons in the same room can overlap in time. | |
| struct AssignedLessonSlot { | |
| lesson_index: usize, | |
| room_idx: usize, | |
| day_of_week: Weekday, | |
| start_time: NaiveTime, | |
| end_time: NaiveTime, | |
| } | |
| pub fn constraint() -> impl IncrementalConstraint<Plan, HardMediumSoftScore> { | |
| use solverforge::stream::joiner::{equal, equal_bi}; | |
| ConstraintFactory::<Plan, HardMediumSoftScore>::new() | |
| .for_each(Plan::lessons()) | |
| // First attach the assigned timeslot to each lesson. Unassigned | |
| // lessons do not join here, so this rule does not duplicate the medium | |
| // assignment penalty. | |
| .join(( | |
| ConstraintFactory::<Plan, HardMediumSoftScore>::new().for_each(Plan::timeslots()), | |
| equal_bi( | |
| |lesson: &Lesson| lesson.timeslot_idx, | |
| |timeslot: &Timeslot| Some(timeslot.index), | |
| ), | |
| )) | |
| // Reduce each joined row to the fields needed to detect a room | |
| // collision. This keeps the later pairwise join small and readable. | |
| .project(|lesson: &Lesson, timeslot: &Timeslot| AssignedLessonSlot { | |
| lesson_index: lesson.index, | |
| room_idx: lesson.room_idx.unwrap_or(usize::MAX), | |
| day_of_week: timeslot.day_of_week, | |
| start_time: timeslot.start_time, | |
| end_time: timeslot.end_time, | |
| }) | |
| .join(equal(|row: &AssignedLessonSlot| row.room_idx)) | |
| // `lesson_index < b.lesson_index` avoids scoring the same conflicting | |
| // pair twice. The strict time comparisons implement ordinary interval | |
| // overlap, so a lesson ending at 10:00 does not conflict with one | |
| // starting at 10:00. | |
| .filter(|a: &AssignedLessonSlot, b: &AssignedLessonSlot| { | |
| a.lesson_index < b.lesson_index | |
| && a.day_of_week == b.day_of_week | |
| && a.start_time < b.end_time | |
| && b.start_time < a.end_time | |
| }) | |
| .penalize(hard_weight( | |
| |_: &AssignedLessonSlot, _: &AssignedLessonSlot| HardMediumSoftScore::of_hard(1), | |
| )) | |
| .named("No Room Conflict") | |
| } | |
| mod tests { | |
| use crate::domain::Room; | |
| use super::*; | |
| fn time(hour: u32, min: u32) -> NaiveTime { | |
| NaiveTime::from_hms_opt(hour, min, 0).unwrap() | |
| } | |
| // Helper that only runs the No Room Conflict constraint | |
| fn evaluate_only_room_conflict(plan: &Plan) -> HardMediumSoftScore { | |
| let constraint_set = (constraint(),); | |
| constraint_set.evaluate_all(plan) | |
| } | |
| fn detects_room_conflict_same_timeslot() { | |
| let rooms = vec![Room::new(0, "Room A")]; | |
| let timeslots = vec![Timeslot::new(0, Weekday::Mon, time(8, 0), time(10, 0))]; | |
| let teachers = vec![]; | |
| // Different groups to avoid group conflict, no teacher assignment to avoid teacher conflict | |
| let mut lessons = vec![ | |
| Lesson::new(0, "Math".to_string(), 0, None, 120), | |
| Lesson::new(1, "Physics".to_string(), 1, None, 120), | |
| ]; | |
| lessons[0].timeslot_idx = Some(0); | |
| lessons[0].room_idx = Some(0); | |
| lessons[1].timeslot_idx = Some(0); | |
| lessons[1].room_idx = Some(0); | |
| let plan = Plan::new(timeslots, teachers, vec![], lessons, rooms); | |
| let score = evaluate_only_room_conflict(&plan); | |
| assert_eq!(score.hard(), -1, "Expected hard score -1 for room conflict"); | |
| } | |
| fn detects_room_conflict_overlapping_timeslots() { | |
| let rooms = vec![Room::new(0, "Room A")]; | |
| let timeslots = vec![ | |
| Timeslot::new(0, Weekday::Mon, time(8, 0), time(10, 0)), | |
| Timeslot::new(1, Weekday::Mon, time(9, 0), time(11, 0)), | |
| ]; | |
| let teachers = vec![]; | |
| let mut lessons = vec![ | |
| Lesson::new(0, "Math".to_string(), 0, None, 120), | |
| Lesson::new(1, "Physics".to_string(), 1, None, 120), | |
| ]; | |
| lessons[0].timeslot_idx = Some(0); | |
| lessons[0].room_idx = Some(0); | |
| lessons[1].timeslot_idx = Some(1); | |
| lessons[1].room_idx = Some(0); | |
| let plan = Plan::new(timeslots, teachers, vec![], lessons, rooms); | |
| let score = evaluate_only_room_conflict(&plan); | |
| assert_eq!( | |
| score.hard(), | |
| -1, | |
| "Expected hard score -1 for overlapping room conflict" | |
| ); | |
| } | |
| fn no_conflict_different_rooms() { | |
| let rooms = vec![Room::new(0, "Room A"), Room::new(1, "Room B")]; | |
| let timeslots = vec![Timeslot::new(0, Weekday::Mon, time(8, 0), time(10, 0))]; | |
| let teachers = vec![]; | |
| let mut lessons = vec![ | |
| Lesson::new(0, "Math".to_string(), 0, None, 120), | |
| Lesson::new(1, "Physics".to_string(), 0, None, 120), | |
| ]; | |
| lessons[0].timeslot_idx = Some(0); | |
| lessons[0].room_idx = Some(0); | |
| lessons[1].timeslot_idx = Some(0); | |
| lessons[1].room_idx = Some(1); | |
| let plan = Plan::new(timeslots, teachers, vec![], lessons, rooms); | |
| let score = evaluate_only_room_conflict(&plan); | |
| assert_eq!(score.hard(), 0, "Expected hard score 0 for different rooms"); | |
| } | |
| fn no_conflict_non_overlapping_timeslots() { | |
| let rooms = vec![Room::new(0, "Room A")]; | |
| let timeslots = vec![ | |
| Timeslot::new(0, Weekday::Mon, time(8, 0), time(10, 0)), | |
| Timeslot::new(1, Weekday::Mon, time(10, 0), time(12, 0)), | |
| ]; | |
| let teachers = vec![]; | |
| let mut lessons = vec![ | |
| Lesson::new(0, "Math".to_string(), 0, None, 120), | |
| Lesson::new(1, "Physics".to_string(), 0, None, 120), | |
| ]; | |
| lessons[0].timeslot_idx = Some(0); | |
| lessons[0].room_idx = Some(0); | |
| lessons[1].timeslot_idx = Some(1); | |
| lessons[1].room_idx = Some(0); | |
| let plan = Plan::new(timeslots, teachers, vec![], lessons, rooms); | |
| let score = evaluate_only_room_conflict(&plan); | |
| assert_eq!( | |
| score.hard(), | |
| 0, | |
| "Expected hard score 0 for non-overlapping timeslots" | |
| ); | |
| } | |
| fn no_conflict_unassigned_room() { | |
| let rooms = vec![Room::new(0, "Room A")]; | |
| let timeslots = vec![Timeslot::new(0, Weekday::Mon, time(8, 0), time(10, 0))]; | |
| let teachers = vec![]; | |
| let mut lessons = vec![ | |
| Lesson::new(0, "Math".to_string(), 0, None, 120), | |
| Lesson::new(1, "Physics".to_string(), 1, None, 120), | |
| ]; | |
| lessons[0].timeslot_idx = Some(0); | |
| lessons[0].room_idx = None; // Unassigned | |
| lessons[1].timeslot_idx = Some(0); | |
| lessons[1].room_idx = Some(0); | |
| let plan = Plan::new(timeslots, teachers, vec![], lessons, rooms); | |
| let score = evaluate_only_room_conflict(&plan); | |
| assert_eq!(score.hard(), 0, "Expected hard score 0 for unassigned room"); | |
| } | |
| } | |