From 3461f9485416d6b65034bf3af317fd3a2ef0906b Mon Sep 17 00:00:00 2001 From: James Kolpack Date: Mon, 1 Dec 2025 06:58:56 -0500 Subject: [PATCH] Refactor TeamScheduler, easier to read and maintain --- Core/Calculation/TeamScheduleTimeSlot.cs | 31 ++++++- Core/Calculation/TeamScheduler.cs | 91 ++++++++++++++----- Core/Calculation/TeamSchedulerSolution.cs | 59 +++++++++--- Tests/Calculation/TeamSchedulerTest.cs | 4 +- Web-Original/Views/Home/Schedule.cshtml | 6 +- .../Views/Home/ScheduleTeamPartial.cshtml | 4 +- .../Pages/MeetingSchedulePages/Index.razor | 16 ++++ 7 files changed, 161 insertions(+), 50 deletions(-) diff --git a/Core/Calculation/TeamScheduleTimeSlot.cs b/Core/Calculation/TeamScheduleTimeSlot.cs index e6f8834..646100f 100644 --- a/Core/Calculation/TeamScheduleTimeSlot.cs +++ b/Core/Calculation/TeamScheduleTimeSlot.cs @@ -2,15 +2,38 @@ namespace Core.Calculation; +/// +/// Represents a single time slot in the team meeting schedule. +/// public class TeamScheduleTimeSlot { + /// + /// Gets or sets the name of this time slot. + /// public string Name { get; set; } - public Team[] Teams; - public Student[] UnscheduledStudents; - public IEnumerable>> StudentOverlaps; + /// + /// Gets or sets the teams scheduled in this time slot. + /// + public Team[] Teams; + + /// + /// Gets or sets the students who are not scheduled in any team during this time slot. + /// + public Student[] UnscheduledStudents; + + /// + /// Gets or sets the students who have overlapping team meetings in this time slot. + /// + public IEnumerable<(Student student, IEnumerable teams)> StudentOverlaps; + + /// + /// Checks if a student has overlapping team meetings in this time slot. + /// + /// The student to check + /// True if the student has overlaps, otherwise false public bool StudentHasOverlaps(Student student) { - return StudentOverlaps.FirstOrDefault(o => o.Item1.Equals(student)) != null; + return StudentOverlaps.Any(o => o.student.Equals(student)); } } \ No newline at end of file diff --git a/Core/Calculation/TeamScheduler.cs b/Core/Calculation/TeamScheduler.cs index f8a4ee6..fb43303 100644 --- a/Core/Calculation/TeamScheduler.cs +++ b/Core/Calculation/TeamScheduler.cs @@ -3,69 +3,110 @@ using Core.Entities; using Google.OrTools.Sat; namespace Core.Calculation; + +/// +/// Solves the team meeting scheduling problem using constraint programming. +/// Assigns teams to time slots while minimizing student schedule conflicts. +/// public class TeamScheduler { private readonly IList _studentObjects; private readonly IList _teamObjects; + private readonly double _maxSolveTimeSeconds; private readonly int[] _students; private readonly int[] _teams; private readonly int[] _timeSlots; - private readonly List> _scheduleSeparateTeams = []; + private readonly List<(int team1, int team2)> _scheduleSeparateTeams = []; - public TeamScheduler(IEnumerable teams, int numTimeSlots, IEnumerable allStudents) + /// + /// Creates a new team scheduler instance. + /// + /// The teams to schedule (must not be null or empty) + /// The number of available time slots (must be positive) + /// All students participating in teams (must not be null or empty) + /// Maximum solver time in seconds (default: 10.0) + /// Thrown when teams or allStudents is null + /// Thrown when collections are empty or numTimeSlots is invalid + public TeamScheduler(IEnumerable teams, int numTimeSlots, IEnumerable allStudents, double maxSolveTimeSeconds = 10.0) { + if (teams == null) + throw new ArgumentNullException(nameof(teams)); + if (allStudents == null) + throw new ArgumentNullException(nameof(allStudents)); + if (numTimeSlots <= 0) + throw new ArgumentException("Number of time slots must be positive", nameof(numTimeSlots)); + _teamObjects = teams.ToArray(); - //_studentObjects = teams.SelectMany(t => t.Students).Distinct().ToList(); - _studentObjects = allStudents.ToList(); + _studentObjects = allStudents.ToList(); + _maxSolveTimeSeconds = maxSolveTimeSeconds; + + if (_teamObjects.Count == 0) + throw new ArgumentException("Teams collection cannot be empty", nameof(teams)); + if (_studentObjects.Count == 0) + throw new ArgumentException("Students collection cannot be empty", nameof(allStudents)); _students = Enumerable.Range(0, _studentObjects.Count).ToArray(); _teams = Enumerable.Range(0, _teamObjects.Count).ToArray(); _timeSlots = Enumerable.Range(0, numTimeSlots).ToArray(); } + /// + /// Adds a constraint requiring two teams to be scheduled in different time slots. + /// + /// First team + /// Second team + /// Thrown when either team is not found in the scheduler public void ScheduleSeparate(Team team1, Team team2) { var one = _teamObjects.IndexOf(team1); var two = _teamObjects.IndexOf(team2); - _scheduleSeparateTeams.Add(Tuple.Create(one,two)); - } - - public static TeamScheduler CreateInstance(IEnumerable teams, int numTimeSlots, IEnumerable allStudents) - { - return new TeamScheduler(teams, numTimeSlots, allStudents); + + if (one == -1) + throw new ArgumentException($"Team '{team1}' not found in scheduler", nameof(team1)); + if (two == -1) + throw new ArgumentException($"Team '{team2}' not found in scheduler", nameof(team2)); + + _scheduleSeparateTeams.Add((one, two)); } + /// + /// Solves the team scheduling problem using constraint programming. + /// Minimizes the number of time slots where students have conflicting team meetings. + /// + /// A solution containing team assignments to time slots and conflict information public TeamSchedulerSolution Solve() { - // Model. + // Create constraint programming model var model = new CpModel(); - // Data + // Build membership matrix: m[i,t] = 1 if student i is on team t, else 0 var m = new int[_students.Length,_teams.Length]; foreach (var i in _students) foreach (var t in _teams) m[i, t] = _studentObjects[i].Teams.Contains(_teamObjects[t]) ? 1 : 0; - // Variables. - // x - 1 if meeting of team t takes place at time slot s, else 0 + // Decision variables: + // x[t,s] = 1 if meeting of team t takes place at time slot s, else 0 var x = new IntVar[_teams.Length, _timeSlots.Length]; foreach (var t in _teams) foreach (var s in _timeSlots) x[t, s] = model.NewIntVar(0, 1,$"team time slots[{t},{s}]"); - // y - 1 if individual i has meetings at time slot s, 0 otherwise + // y[i,s] = 1 if student i has at least one meeting at time slot s, else 0 var y = new IntVar[_students.Length, _timeSlots.Length]; foreach (var i in _students) foreach (var s in _timeSlots) y[i, s] = model.NewIntVar(0, 1, $"individual time slots[{i},{s}]"); - // each team meets exactly one time + // Constraint: each team meets exactly once foreach (var t in _teams) model.AddLinearConstraint(LinearExpr.Sum(_timeSlots.Select(s => x[t, s])), 1L, 1L); - - // individual must have at least one team meeting at the given time slot to attend + + // Constraint: Link y[i,s] to whether student i has meetings at slot s + // y[i,s] <= sum over all teams t of (m[i,t] * x[t,s]) + // This forces y[i,s] to be 1 if student i has at least one team meeting at slot s foreach (var i in _students) foreach (var s in _timeSlots) model.Add( @@ -74,19 +115,21 @@ public class TeamScheduler LinearExpr.Sum(_teams.Select(t => m[i, t] * x[t, s])), false)); - // maximize number of times individuals meet + // Objective: minimize the sum of y[i,s] values (minimize student conflicts) var indTimeSlotVars = LinearExpr.NewBuilder(); foreach (var i in _students) foreach (var s in _timeSlots) indTimeSlotVars.Add(y[i, s]); model.Minimize(indTimeSlotVars); - foreach (var ts in _scheduleSeparateTeams) + // Constraint: teams marked as "separate" must be in different time slots + foreach (var (team1, team2) in _scheduleSeparateTeams) foreach (var s in _timeSlots) - model.Add(x[ts.Item1, s] != x[ts.Item2, s]); + model.Add(x[team1, s] != x[team2, s]); + // Configure and run solver var solver = new CpSolver(); - solver.StringParameters = "max_time_in_seconds:2.0"; + solver.StringParameters = $"max_time_in_seconds:{_maxSolveTimeSeconds}"; var cpSolverStatus = solver.Solve(model); Debug.WriteLine($"Solver status: {cpSolverStatus}"); @@ -95,15 +138,13 @@ public class TeamScheduler if (cpSolverStatus is not (CpSolverStatus.Optimal or CpSolverStatus.Feasible)) return new TeamSchedulerSolution(timeSlotTeams, _studentObjects.ToArray(), cpSolverStatus.ToString()); - // Debug.WriteLine($"Total cost: {solver.ObjectiveValue}\n"); - + // Extract solution: which teams are assigned to each time slot foreach (var s in _timeSlots) { var teams = (from t in _teams where solver.Value(x[t, s]) > 0 select _teamObjects[t]).ToArray(); timeSlotTeams[s] = teams; } - //Debug.WriteLine("No solution found."); return new TeamSchedulerSolution(timeSlotTeams, _studentObjects.ToArray(), cpSolverStatus.ToString()); } } \ No newline at end of file diff --git a/Core/Calculation/TeamSchedulerSolution.cs b/Core/Calculation/TeamSchedulerSolution.cs index 5701fa4..662a92e 100644 --- a/Core/Calculation/TeamSchedulerSolution.cs +++ b/Core/Calculation/TeamSchedulerSolution.cs @@ -2,57 +2,88 @@ namespace Core.Calculation; +/// +/// Represents a solution to the team scheduling problem. +/// Contains team assignments to time slots and information about student scheduling conflicts. +/// public class TeamSchedulerSolution( Team[][] timeSlots, Student[] students, string status) { + /// + /// Gets the solver status (e.g., "Optimal", "Feasible", "Infeasible"). + /// public string Status { get; } = status; - public TeamScheduleTimeSlot[] TimeSlots { get; set; } - = timeSlots.Select( (teams,i) => + /// + /// Gets the scheduled time slots with team assignments and conflict information. + /// + public TeamScheduleTimeSlot[] TimeSlots { get; set; } + = timeSlots.Select( (teams,i) => new TeamScheduleTimeSlot{ - Name = "Time Slot " + (i + 1), + Name = "Time Slot " + (i + 1), Teams = teams, - StudentOverlaps = GetStudentTeamOverlaps(teams), + StudentOverlaps = GetStudentTeamOverlaps(teams), UnscheduledStudents = GetStudentsNotInTimSlot(teams, students) } ).ToArray(); + /// + /// Calculates the total number of student conflicts across all time slots. + /// + /// The scheduled time slots + /// Total count of students with overlapping team meetings public static int GetStudentTeamOverlapCount(Team[][] timeSlots) { return timeSlots.Sum(GetStudentTeamOverlapCount); } + /// + /// Calculates the number of student conflicts in a single time slot. + /// + /// The time slot to analyze + /// Count of students with multiple team meetings in this slot private static int GetStudentTeamOverlapCount(Team[] timeSlot) { return GetStudentTeamOverlaps(timeSlot).Count(); } - public static IEnumerable>> GetStudentTeamOverlaps(Team[] timeSlot) + /// + /// Identifies students who have multiple team meetings in the same time slot. + /// + /// The time slot to analyze + /// Students and their conflicting teams in this time slot + public static IEnumerable<(Student student, IEnumerable teams)> GetStudentTeamOverlaps(Team[] timeSlot) { return from s in timeSlot.SelectMany(ts => ts.Students).Distinct() group s by timeSlot.Where(t => t.Students.Contains(s)) into gs where gs.Key.Count() > 1 - select Tuple.Create(gs.First(), gs.Key); + select (gs.First(), gs.Key); } + /// + /// Identifies students who have no team meetings in the given time slot. + /// + /// The time slot to analyze + /// All students + /// Students not scheduled in this time slot public static Student[] GetStudentsNotInTimSlot(Team[] timeSlot, Student[] students) { - var studentsInTimeSlot = timeSlot.SelectMany(ts => ts.Students).Distinct(); - return - (from allStudent in students - where studentsInTimeSlot.FirstOrDefault(e => e.Equals(allStudent)) == null - select allStudent - ).ToArray(); + var studentsInTimeSlot = timeSlot.SelectMany(ts => ts.Students).Distinct().ToHashSet(); + return students.Where(s => !studentsInTimeSlot.Contains(s)).ToArray(); } + /// + /// Gets the teams a student is on that weren't assigned to any time slot. + /// + /// The student to check + /// Teams the student is on that have no scheduled meeting public Team[] StudentUnassignedTeams(Student student) { var meetingTeams = TimeSlots.SelectMany(t => t.Teams); - return - student.Teams.Where(e => !meetingTeams.Contains(e)).ToArray(); + return student.Teams.Where(e => !meetingTeams.Contains(e)).ToArray(); } } \ No newline at end of file diff --git a/Tests/Calculation/TeamSchedulerTest.cs b/Tests/Calculation/TeamSchedulerTest.cs index 0acd25e..8c598aa 100644 --- a/Tests/Calculation/TeamSchedulerTest.cs +++ b/Tests/Calculation/TeamSchedulerTest.cs @@ -39,7 +39,7 @@ public class TeamSchedulerTest TeamSchedulerSolution solution; if (true) { - var teamScheduler = TeamScheduler.CreateInstance(teams, 3, students); + var teamScheduler = new TeamScheduler(teams, 3, students); solution = teamScheduler.Solve(); } else @@ -69,7 +69,7 @@ public class TeamSchedulerTest Console.WriteLine("\toverlaps"); foreach (var overlap in overlaps) Console.WriteLine( - $"\t\t{overlap.Item1.Name} : {string.Join(", ", overlap.Item2.Select(t => t.Event.Name))}"); + $"\t\t{overlap.student.Name} : {string.Join(", ", overlap.teams.Select(t => t.Event.Name))}"); } var unassigned = UnassignedStudentScheduler.UnassignedStudents(students, slot.Teams).ToList(); diff --git a/Web-Original/Views/Home/Schedule.cshtml b/Web-Original/Views/Home/Schedule.cshtml index 37470d4..24389a4 100644 --- a/Web-Original/Views/Home/Schedule.cshtml +++ b/Web-Original/Views/Home/Schedule.cshtml @@ -12,13 +12,13 @@ var allMeetingTeams = schedule.SelectMany(t => t).Distinct(); } -
+
@{ - List>> overlaps; + List<(Student student, IEnumerable teams)> overlaps; } @foreach (var timeslot in schedule) { - overlaps = Team.GetStudentTeamOverlaps(timeslot).ToList(); + overlaps = TeamSchedulerSolution.GetStudentTeamOverlaps(timeslot).ToList(); var partialTeams = timeslot.Where(t => t is PartialTeam && t.EventDefinition.EventFormat is not EventFormat.Individual); var fullTeams = timeslot.Where(t => !partialTeams.Contains(t)); diff --git a/Web-Original/Views/Home/ScheduleTeamPartial.cshtml b/Web-Original/Views/Home/ScheduleTeamPartial.cshtml index 3dcf8c2..9400684 100644 --- a/Web-Original/Views/Home/ScheduleTeamPartial.cshtml +++ b/Web-Original/Views/Home/ScheduleTeamPartial.cshtml @@ -1,5 +1,5 @@ @using Core.Entities -@model Tuple>>, string[]?> +@model Tuple teams)>, string[]?> @{ var team = Model.Item1; @@ -36,7 +36,7 @@ { first = false; } - @if (overlaps.Any(t => t.Item1 == student)) + @if (overlaps.Any(t => t.student == student)) { @student.FirstName } diff --git a/WebApp/Components/Pages/MeetingSchedulePages/Index.razor b/WebApp/Components/Pages/MeetingSchedulePages/Index.razor index 709cb2a..9093ecc 100644 --- a/WebApp/Components/Pages/MeetingSchedulePages/Index.razor +++ b/WebApp/Components/Pages/MeetingSchedulePages/Index.razor @@ -207,9 +207,25 @@ { _isSolving = true; + // Check if there are any teams to schedule + if (!_scheduledTeams.Any()) + { + _isSolving = false; + _solution = new TeamSchedulerSolution([], [], "No teams selected"); + return new TableData { Items = [] }; + } + // Filter out absent students var availableStudents = _students.Where(s => !_absentStudents.Contains(s)).ToArray(); + // Check if there are any available students + if (availableStudents.Length == 0) + { + _isSolving = false; + _solution = new TeamSchedulerSolution([], [], "No available students"); + return new TableData { Items = [] }; + } + // Update parameters with absent student names _parameters.AbsentStudents = _absentStudents.Select(s => s.FirstNameLastName).ToArray();