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
+}