using System.Diagnostics; using Core.Entities; using Core.Models; using Google.OrTools.Sat; 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 { // 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 double _maxSolveTimeSeconds; private readonly int[] _allEvents; private readonly int[] _allStudents; // how many students have picked each event? private readonly int[] _eventPickCounts; private IList _assignmentRequirements = []; private IList _droppedEvents = []; private IList _includedEvents = []; private IList _twoTeams = []; /// /// 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) { 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; _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]; // 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); } /// /// Marks events to be excluded from the assignment solution. /// /// Events to drop from consideration public void RemoveEvents(IList events) { _droppedEvents = 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() { try { Debug.WriteLine(_parameters); // Create constraint programming model var model = new CpModel(); // 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}]"); // Add all constraints AddAssignmentRequirements(model, x); var assignmentThresholdsList = AddEventAssignmentThresholds(model, x); LimitStudentAssignment(model, x); SetLevelOfEffort(model, x); if (_parameters.RequireOnSite) RequireOnSiteActivity(model, x); if (_parameters.RequireRegional) RequireRegionalEvent(model, x); AtMostOneIndividualEvent(model, x); IndividualEventsMustBeRanked(model, x); OptimizeStudentEventRankings(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)); Debug.WriteLine($"Solver status: {cpSolverStatus}"); if (cpSolverStatus == CpSolverStatus.Infeasible) { Debug.WriteLine("Problem is infeasible - constraints cannot be satisfied"); } var eventAssignmentsList = GetEventAssignments(x, solver, cpSolverStatus); var eventAssignmentSolution = new EventAssignmentSolution ( eventAssignmentsList.ToArray(), cpSolverStatus.ToString(), assignmentThresholdsList ); return eventAssignmentSolution; } catch (Exception ex) { Debug.WriteLine($"Error solving event assignment: {ex}"); throw; } } /// /// 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)) return []; var eventAssignments = from e in _allEvents 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], Students = students.ToList() }; return eventAssignments.ToList(); } /// /// 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); foreach (var e in _allEvents) { maximizePicks.AddTerm(x[e, s], eventPickCoefficients[e]); } } model.Maximize(maximizePicks); } /// /// Constraint: Each student must have 1-2 regional events. /// private void RequireRegionalEvent(CpModel model, BoolVar[,] x) { 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) { 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) { List buffer = []; foreach (var s in _allStudents) { buffer.Clear(); foreach (var e in _allEvents) { if (eventFilter(_events[e])) buffer.Add(x[e, s]); } 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) { List prohibitVar = []; foreach (var s in _allStudents) { var student = _students[s]; foreach (var e in _allEvents) { var evt = _events[e]; 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); } } } } /// /// Constraint: Each student's total level of effort must be within bounds. /// private void SetLevelOfEffort(CpModel model, BoolVar[,] x) { long[] eventEffortCoefficients = _events.Select( e => e.LevelOfEffort ?? 1L ).ToArray(); foreach (var s in _allStudents) { var effortVar = new BoolVar[_allEvents.Length]; foreach (var e in _allEvents) { effortVar[e] = x[e, s]; } var student = _students[s]; var experienceOffset = student.TsaYear == 1 ? 1 : 0; var ub = _parameters.EffortUpperBound - experienceOffset; var lb = _parameters.EffortLowerBound; if (ub <= lb) lb = ub - 1; model.Add(LinearExpr.WeightedSum(effortVar, eventEffortCoefficients) >= lb); model.Add(LinearExpr.WeightedSum(effortVar, eventEffortCoefficients) <= ub); } } /// /// Constraint: Limit the number of events a student is assigned. /// private void LimitStudentAssignment(CpModel model, BoolVar[,] x) { List studentCapacity = []; foreach (var s in _allStudents) { studentCapacity.Clear(); foreach (var e in _allEvents) { studentCapacity.Add(x[e, s]); } model.AddLinearConstraint( LinearExpr.Sum(studentCapacity), _parameters.EventsLowerBound, _parameters.EventsUpperBound); } } /// /// Adds event capacity constraints and calculates assignment thresholds. /// private List AddEventAssignmentThresholds(CpModel model, BoolVar[,] x) { var assignmentThresholdsList = new List(); foreach (var e in _allEvents) { var evt = _events[e]; var teamCount = CalculateTeamCount(evt, e); var (lb, ub) = CalculateEventBounds(evt, teamCount); AddEventConstraint(model, x, e, lb, ub); assignmentThresholdsList.Add(CreateThreshold(evt, teamCount, e)); Debug.WriteLine($"{evt.Name,30}\t{evt.EventFormat,-10}\t{lb} - {ub}"); } 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) { List eventCapacity = []; 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(a => a.Requirement == Requirement.Include)) { var e = _events.IndexOf(includedAssignment.EventDefinition); var s = _students.IndexOf(includedAssignment.Student); model.AddAssumption(x[e, s]); } List prohibitVar = []; foreach (var excludedAssignment in _assignmentRequirements.Where(a => a.Requirement == Requirement.Exclude)) { var e = _events.IndexOf(excludedAssignment.EventDefinition); var s = _students.IndexOf(excludedAssignment.Student); 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 => { var eventRank = eventRankings.FirstOrDefault(er => er.EventDefinition == e); return eventRank == null ? 0L : StudentEventRanking.MaxRank - eventRank.Rank; // Inverse ranking }).ToArray(); } } }