diff --git a/Core/Calculation/EventAssignment.cs b/Core/Calculation/EventAssignment.cs index 45e291a..4681b79 100644 --- a/Core/Calculation/EventAssignment.cs +++ b/Core/Calculation/EventAssignment.cs @@ -1,150 +1,209 @@ -using System.Diagnostics; -using System.Security.Cryptography.X509Certificates; +using System.Diagnostics; using Core.Entities; using Google.OrTools.Sat; -using Microsoft.EntityFrameworkCore.Metadata; -using IntVar = Google.OrTools.Sat.IntVar; namespace Core.Calculation { + /// + /// Solves the event assignment problem using constraint programming. + /// Assigns students to events based on rankings, capacity constraints, and team requirements. + /// public class EventAssignment { - private readonly IList _events; + // Constants for magic numbers + private const double TEAM_DIVISION_MULTIPLIER = 1.25; + private const int MIN_REGIONAL_EVENTS = 1; + private const int MAX_REGIONAL_EVENTS = 2; + + private readonly IList _events; private readonly IList _students; private readonly AssignmentParameters _parameters; - private readonly int[] _allEvents; + private readonly double _maxSolveTimeSeconds; + private readonly int[] _allEvents; private readonly int[] _allStudents; - // how many students have picked each eventDefinition? + // how many students have picked each event? private readonly int[] _eventPickCounts; - private IList _assignmentRequirements = new List(); - private IList _droppedEvents = new List(); - private IList _includedEvents = new List(); - private IList _twoTeams = new List(); + private IList _assignmentRequirements = []; + private IList _droppedEvents = []; + private IList _includedEvents = []; + private IList _twoTeams = []; - public EventAssignment(IList events, IList students, AssignmentParameters parameters) + /// + /// Creates a new event assignment optimizer. + /// + /// The events to assign students to (must not be null or empty) + /// The students to assign to events (must not be null or empty) + /// Assignment parameters and constraints + /// Maximum solver time in seconds (default: 60.0) + /// Thrown when events, students, or parameters is null + /// Thrown when collections are empty + public EventAssignment(IList events, IList students, + AssignmentParameters parameters, double maxSolveTimeSeconds = 60.0) { - _events = events; + if (events == null) + throw new ArgumentNullException(nameof(events)); + if (students == null) + throw new ArgumentNullException(nameof(students)); + if (parameters == null) + throw new ArgumentNullException(nameof(parameters)); + + if (events.Count == 0) + throw new ArgumentException("Events collection cannot be empty", nameof(events)); + if (students.Count == 0) + throw new ArgumentException("Students collection cannot be empty", nameof(students)); + + _events = events; _students = students; _parameters = parameters; - _allEvents = Enumerable.Range(0, _events.Count).ToArray(); + _maxSolveTimeSeconds = maxSolveTimeSeconds; + _allEvents = Enumerable.Range(0, _events.Count).ToArray(); _allStudents = Enumerable.Range(0, _students.Count).ToArray(); _eventPickCounts = new int[_allEvents.Length]; + for (var i = 0; i < _events.Count; i++) { var e = _events[i]; - - _eventPickCounts[i] = _students.Count(s => s.EventRankings.Count(er => er.EventDefinition == e) > 0); + // Performance: Use Any() instead of Count() > 0 + _eventPickCounts[i] = _students.Count(s => s.EventRankings.Any(er => er.EventDefinition == e)); } } + /// + /// Adds a requirement to include or exclude a specific student-event pairing. + /// + /// The assignment requirement to add public void AddAssignmentRequirement(AssignmentRequirement assignmentRequirement) { _assignmentRequirements.Add(assignmentRequirement); } - public void RemoveEvents(IList events) + /// + /// Marks events to be excluded from the assignment solution. + /// + /// Events to drop from consideration + public void RemoveEvents(IList events) { _droppedEvents = events; } - public void IncludedEvents(IList events) + /// + /// Forces specific events to be included in the solution. + /// + /// Events that must be included + public void SetIncludedEvents(IList events) { _includedEvents = events; } + /// + /// Allows specific events to have two teams instead of one. + /// + /// Events that can have two teams public void AllowTwoTeams(IList events) { _twoTeams = events; } + /// + /// Solves the event assignment problem using constraint programming. + /// Assigns students to events based on rankings, capacity constraints, and requirements. + /// + /// A solution containing team assignments and status public async Task Solve() { - Debug.WriteLine(_parameters); + try + { + Debug.WriteLine(_parameters); - // Model. - var model = new CpModel(); + // Create constraint programming model + var model = new CpModel(); - // Variables. - var x = new BoolVar[_allEvents.Length, _allStudents.Length]; - foreach (var e in _allEvents) - foreach (var s in _allStudents) - x[e, s] = model.NewBoolVar($"eventAssignments[{e},{s}]"); + // Decision variables: x[e,s] = 1 if student s is assigned to event e + var x = new BoolVar[_allEvents.Length, _allStudents.Length]; + foreach (var e in _allEvents) + foreach (var s in _allStudents) + x[e, s] = model.NewBoolVar($"eventAssignments[{e},{s}]"); - AddAssignmentRequirements(model, x); + // Add all constraints + AddAssignmentRequirements(model, x); + var assignmentThresholdsList = AddEventAssignmentThresholds(model, x); + LimitStudentAssignment(model, x); + SetLevelOfEffort(model, x); - var assignmentThresholdsList = AddEventAssignmentThresholds(model, x); + if (_parameters.RequireOnSite) + RequireOnSiteActivity(model, x); - LimitStudentAssignment(model, x); + if (_parameters.RequireRegional) + RequireRegionalEvent(model, x); - // set the range for level of effort - SetLevelOfEffort(model, x); - - // each student should be assigned at least one on site activity - if (_parameters.RequireOnSite) - RequireOnSiteActivity(model, x); - - // students should have at least one regional event - if (_parameters.RequireRegional) - RequireRegionalEvent(model, x); + AtMostOneIndividualEvent(model, x); + IndividualEventsMustBeRanked(model, x); + OptimizeStudentEventRankings(model, x); - // students should have at maximum one individual event - AtMostOneIndividualEvent(model, x); + // Configure and run solver + Debug.WriteLine("Starting optimization"); + var solver = new CpSolver(); + solver.StringParameters = $"max_time_in_seconds:{_maxSolveTimeSeconds}"; + var cpSolverStatus = await Task.Run(() => solver.Solve(model)); - //EventHasInterestedStudent(model, x); + Debug.WriteLine($"Solver status: {cpSolverStatus}"); - IndividualEventsMustBeRanked(model, x); + if (cpSolverStatus == CpSolverStatus.Infeasible) + { + Debug.WriteLine("Problem is infeasible - constraints cannot be satisfied"); + } - OptimizeStudentEventRankings(model, x); + var eventAssignmentsList = GetEventAssignments(x, solver, cpSolverStatus); + var eventAssignmentSolution = + new EventAssignmentSolution + ( + eventAssignmentsList.ToArray(), + cpSolverStatus.ToString(), + assignmentThresholdsList + ); - Debug.WriteLine("Starting optimization"); - var solver = new CpSolver(); - var cpSolverStatus = await Task.Run(() => solver.Solve(model)); - - // print solver status - Debug.WriteLine($"Solver status: {cpSolverStatus}"); - - var eventAssignmentsList = GetEventAssignments(x, solver, cpSolverStatus); - - var eventAssignmentSolution = - new EventAssignmentSolution - ( - eventAssignmentsList.ToArray(), - cpSolverStatus.ToString(), - assignmentThresholdsList - ); - - return eventAssignmentSolution; + return eventAssignmentSolution; + } + catch (Exception ex) + { + Debug.WriteLine($"Error solving event assignment: {ex}"); + throw; + } } - // Take the solution and map it back to the entities + /// + /// Extracts the solution from the solver results. + /// private List GetEventAssignments(BoolVar[,] x, CpSolver solver, CpSolverStatus cpSolverStatus) { - if (cpSolverStatus is not (CpSolverStatus.Optimal or CpSolverStatus.Feasible)) + if (cpSolverStatus is not (CpSolverStatus.Optimal or CpSolverStatus.Feasible)) return []; var eventAssignments = from e in _allEvents - let students = - from s in _allStudents - where solver.BooleanValue(x[e, s]) + let students = + from s in _allStudents + where solver.BooleanValue(x[e, s]) select _students[s] where students.Any() select new Team { - Identifier = _events[e].Name, - Event = _events[e], + Identifier = _events[e].Name, + Event = _events[e], Students = students.ToList() }; return eventAssignments.ToList(); } - // Maximize student event rankings + /// + /// Objective function: Maximize student event rankings (students get higher-ranked events). + /// private void OptimizeStudentEventRankings(CpModel model, BoolVar[,] x) { var maximizePicks = LinearExpr.NewBuilder(); - + foreach (var s in _allStudents) { var eventPickCoefficients = GetEventPickCoefficients(_events, _students[s].EventRankings); @@ -157,58 +216,64 @@ namespace Core.Calculation model.Maximize(maximizePicks); } + /// + /// Constraint: Each student must have 1-2 regional events. + /// private void RequireRegionalEvent(CpModel model, BoolVar[,] x) { - foreach (var s in _allStudents) - { - var regionalEvent = new List(); - foreach (var e in _allEvents) - { - if (_events[e].RegionalEvent) - regionalEvent.Add(x[e, s]); - } - - // between 1 and 2 regional events - model.AddLinearConstraint(LinearExpr.Sum(regionalEvent), 1, 2); - regionalEvent.Clear(); - } + AddStudentConstraint(model, x, + evt => evt.RegionalEvent, + (m, list) => m.AddLinearConstraint(LinearExpr.Sum(list), MIN_REGIONAL_EVENTS, MAX_REGIONAL_EVENTS)); } + /// + /// Constraint: Each student can have at most one individual event. + /// private void AtMostOneIndividualEvent(CpModel model, BoolVar[,] x) { - foreach (var s in _allStudents) - { - var individualEvent = new List(); - foreach (var e in _allEvents) - { - if (_events[e].EventFormat == EventFormat.Individual) - individualEvent.Add(x[e, s]); - } - - model.AddAtMostOne(individualEvent); - individualEvent.Clear(); - } + AddStudentConstraint(model, x, + evt => evt.EventFormat == EventFormat.Individual, + (m, list) => m.AddAtMostOne(list)); } + /// + /// Constraint: Each student must have at least one on-site activity. + /// private void RequireOnSiteActivity(CpModel model, BoolVar[,] x) { + AddStudentConstraint(model, x, + evt => evt.OnSiteActivity, + (m, list) => m.AddAtLeastOne(list)); + } + + /// + /// Helper method to add constraints for all students based on event filters. + /// Reduces code duplication and improves performance by reusing the list buffer. + /// + private void AddStudentConstraint(CpModel model, BoolVar[,] x, + Func eventFilter, Action> constraintAction) + { + var buffer = new List(); foreach (var s in _allStudents) { - var onSiteActivity = new List(); + buffer.Clear(); foreach (var e in _allEvents) { - if (_events[e].OnSiteActivity) - onSiteActivity.Add(x[e, s]); + if (eventFilter(_events[e])) + buffer.Add(x[e, s]); } - - //if (_parameters.RequireOnSite) - model.AddAtLeastOne(onSiteActivity); - onSiteActivity.Clear(); + if (buffer.Count > 0) + constraintAction(model, buffer); } } + /// + /// Constraint: Individual events can only be assigned if the student ranked them. + /// private void IndividualEventsMustBeRanked(CpModel model, BoolVar[,] x) { + var prohibitVar = new List(1); + foreach (var s in _allStudents) { var student = _students[s]; @@ -216,15 +281,20 @@ namespace Core.Calculation { var evt = _events[e]; - var prohibitVar = new List { x[e, s] }; - if (evt.EventFormat == EventFormat.Individual + if (evt.EventFormat == EventFormat.Individual && student.EventRankings.Find(er => er.EventDefinition == evt) == null) + { + prohibitVar.Clear(); + prohibitVar.Add(x[e, s]); model.AddLinearConstraint(LinearExpr.Sum(prohibitVar), 0, 0); - prohibitVar.Clear(); + } } } } + /// + /// Constraint: Each student's total level of effort must be within bounds. + /// private void SetLevelOfEffort(CpModel model, BoolVar[,] x) { long[] eventEffortCoefficients = _events.Select( @@ -240,12 +310,7 @@ namespace Core.Calculation effortVar[e] = x[e, s]; } var student = _students[s]; - var experienceOffset = 0; - switch (student.TsaYear) - { - case 1: experienceOffset = 1; break; - default: break; - } + var experienceOffset = student.TsaYear == 1 ? 1 : 0; var ub = _parameters.EffortUpperBound - experienceOffset; var lb = _parameters.EffortLowerBound; @@ -257,12 +322,16 @@ namespace Core.Calculation } } - // Limit the number of events a student is assigned + /// + /// Constraint: Limit the number of events a student is assigned. + /// private void LimitStudentAssignment(CpModel model, BoolVar[,] x) { + var studentCapacity = new List(); + foreach (var s in _allStudents) { - var studentCapacity = new List(); + studentCapacity.Clear(); foreach (var e in _allEvents) { studentCapacity.Add(x[e, s]); @@ -270,162 +339,146 @@ namespace Core.Calculation model.AddLinearConstraint( LinearExpr.Sum(studentCapacity), _parameters.EventsLowerBound, _parameters.EventsUpperBound); - studentCapacity.Clear(); - } - } - - private void EventHasInterestedStudent(CpModel model, BoolVar[,] x) - { - foreach (var e in _allEvents) - { - var evt = _events[e]; - var studentInterest = new List(); - - foreach (var s in _allStudents) - { - var student = _students[s]; - if (student.EventRankings.Find(er => er.EventDefinition == evt) != null) - studentInterest.Add(x[e, s]); - } - model.AddLinearConstraint( - LinearExpr.Sum(studentInterest), 1, 10); - studentInterest.Clear(); } } + /// + /// Adds event capacity constraints and calculates assignment thresholds. + /// private List AddEventAssignmentThresholds(CpModel model, BoolVar[,] x) { var assignmentThresholdsList = new List(); - // Limit the capacity of each event + foreach (var e in _allEvents) { var evt = _events[e]; - var eventPickCounts = _eventPickCounts[e]; + var teamCount = CalculateTeamCount(evt, e); + var (lb, ub) = CalculateEventBounds(evt, teamCount); - var evtMinTeamSize = evt.MinTeamSize; - var evtMaxTeamSize = evt.MaxTeamSize; - - var teamDivs = eventPickCounts / (evtMinTeamSize * 1.25); - if (_includedEvents.Contains(evt)) - teamDivs = 1; - - //var teamsCount = (int)Math.Ceiling(teamDivs); - var teamCount = (int)Math.Round(teamDivs); - if (teamCount > evt.ChapterEligibilityCountState) - teamCount = evt.ChapterEligibilityCountState; - - // limit to one team for group events - if (_parameters.LimitTeamsToOne - && evt.EventFormat is EventFormat.Team - && teamCount > 1 - && !_twoTeams.Contains(evt) - ) - teamCount = 1; - - if (_twoTeams.Contains(evt)) - teamCount = 2; - - if (evt.Name == "Tech Bowl") - teamCount = 1; - - var eventCapacity = new List(); - foreach (var s in _allStudents) - { - eventCapacity.Add(x[e, s]); - } - - if (_droppedEvents != null && _droppedEvents.Contains(evt)) - teamCount = 0; - - if (evt.EventFormat == EventFormat.Individual) - evtMinTeamSize = 0; - - var lb = evtMinTeamSize * teamCount; - var ub = Math.Min(evtMaxTeamSize * teamCount, _parameters.TeamSizeLimit * teamCount); - - assignmentThresholdsList.Add( - new EventAssignmentThresholds - { - Event = evt, - TeamCount = teamCount, - LowerBound = evtMinTeamSize, - UpperBound = evtMaxTeamSize, - StudentRankingCount = eventPickCounts - }); - - - model.AddLinearConstraint(LinearExpr.Sum(eventCapacity), lb, ub); + AddEventConstraint(model, x, e, lb, ub); + assignmentThresholdsList.Add(CreateThreshold(evt, teamCount, e)); Debug.WriteLine($"{evt.Name,30}\t{evt.EventFormat,-10}\t{lb} - {ub}"); - - model.Minimize(LinearExpr.Sum(eventCapacity)); - eventCapacity.Clear(); } return assignmentThresholdsList; } + /// + /// Calculates how many teams should be formed for an event. + /// + private int CalculateTeamCount(EventDefinition evt, int eventIndex) + { + var eventPickCounts = _eventPickCounts[eventIndex]; + var teamDivs = eventPickCounts / (evt.MinTeamSize * TEAM_DIVISION_MULTIPLIER); + + if (_includedEvents.Contains(evt)) + return 1; + + var teamCount = (int)Math.Round(teamDivs); + if (teamCount > evt.ChapterEligibilityCountState) + teamCount = evt.ChapterEligibilityCountState; + + // Limit to one team for group events + if (_parameters.LimitTeamsToOne + && evt.EventFormat is EventFormat.Team + && teamCount > 1 + && !_twoTeams.Contains(evt)) + teamCount = 1; + + if (_twoTeams.Contains(evt)) + teamCount = 2; + + if (_droppedEvents.Contains(evt)) + teamCount = 0; + + return teamCount; + } + + /// + /// Calculates the lower and upper bounds for event capacity. + /// + private (int lb, int ub) CalculateEventBounds(EventDefinition evt, int teamCount) + { + var evtMinTeamSize = evt.MinTeamSize; + var evtMaxTeamSize = evt.MaxTeamSize; + + if (evt.EventFormat == EventFormat.Individual) + evtMinTeamSize = 0; + + var lb = evtMinTeamSize * teamCount; + var ub = Math.Min(evtMaxTeamSize * teamCount, _parameters.TeamSizeLimit * teamCount); + + return (lb, ub); + } + + /// + /// Adds capacity constraint for a specific event. + /// + private void AddEventConstraint(CpModel model, BoolVar[,] x, int eventIndex, int lb, int ub) + { + var eventCapacity = new List(); + foreach (var s in _allStudents) + { + eventCapacity.Add(x[eventIndex, s]); + } + + model.AddLinearConstraint(LinearExpr.Sum(eventCapacity), lb, ub); + model.Minimize(LinearExpr.Sum(eventCapacity)); + } + + /// + /// Creates a threshold object for tracking event assignment metadata. + /// + private EventAssignmentThresholds CreateThreshold(EventDefinition evt, int teamCount, int eventIndex) + { + return new EventAssignmentThresholds + { + Event = evt, + TeamCount = teamCount, + LowerBound = evt.MinTeamSize, + UpperBound = evt.MaxTeamSize, + StudentRankingCount = _eventPickCounts[eventIndex] + }; + } + + /// + /// Adds user-specified assignment requirements (include/exclude student-event pairs). + /// private void AddAssignmentRequirements(CpModel model, BoolVar[,] x) { - foreach (var includedAssignment in _assignmentRequirements.Where(e => e.Requirement == Requirement.Include)) + foreach (var includedAssignment in _assignmentRequirements.Where(a => a.Requirement == Requirement.Include)) { var e = _events.IndexOf(includedAssignment.EventDefinition); var s = _students.IndexOf(includedAssignment.Student); model.AddAssumption(x[e, s]); } - foreach (var excludedAssignment in _assignmentRequirements.Where(e => e.Requirement == Requirement.Exclude)) + var prohibitVar = new List(1); + foreach (var excludedAssignment in _assignmentRequirements.Where(a => a.Requirement == Requirement.Exclude)) { var e = _events.IndexOf(excludedAssignment.EventDefinition); var s = _students.IndexOf(excludedAssignment.Student); - - var prohibitVar = new List { x[e, s] }; - model.AddLinearConstraint(LinearExpr.Sum(prohibitVar), 0, 0); prohibitVar.Clear(); + prohibitVar.Add(x[e, s]); + model.AddLinearConstraint(LinearExpr.Sum(prohibitVar), 0, 0); } } + /// + /// Calculates coefficients for optimizing student preferences. + /// Higher-ranked events get higher coefficients. + /// private static long[] GetEventPickCoefficients(IList events, List eventRankings) { - return - events.Select(e => + return events.Select(e => { var eventRank = eventRankings.FirstOrDefault(er => er.EventDefinition == e); - return - eventRank == null - ? 0L - // TODO: MaxRank can be calculated - : StudentEventRanking.MaxRank - eventRank.Rank; // inverse + return eventRank == null + ? 0L + : StudentEventRanking.MaxRank - eventRank.Rank; // Inverse ranking }).ToArray(); } - - public class SolutionPrinter : CpSolverSolutionCallback - { - private readonly IList _events; - private readonly IList _students; - private readonly BoolVar[,] _eventAssignment; - - public SolutionPrinter(IList events, IList students, BoolVar[,] eventAssignment) - { - _events = events; - _students = students; - _eventAssignment = eventAssignment; - } - public override void OnSolutionCallback() - { - Console.WriteLine($"Solution "); - foreach (var evt in Enumerable.Range(0, _events.Count)) - { - foreach (var student in Enumerable.Range(0, _students.Count)) - { - if (Value(_eventAssignment[evt, student]) == 1L) - { - - } - } - } - } - } } -} \ No newline at end of file +}