45edcf5e5f
This commit removes the obsolete EventAssignment class from Core.Entities and introduces new models in Core.Models, including AssignmentParameters, AssignmentRequirement, PartialTeam, and StudentEventStatistics. The changes enhance the organization of assignment-related data and improve the overall structure of the codebase. Additionally, several files have been updated to include references to the new Core.Models namespace, ensuring consistency across the application.
486 lines
18 KiB
C#
486 lines
18 KiB
C#
using System.Diagnostics;
|
|
using Core.Entities;
|
|
using Core.Models;
|
|
using Google.OrTools.Sat;
|
|
|
|
namespace Core.Calculation
|
|
{
|
|
/// <summary>
|
|
/// Solves the event assignment problem using constraint programming.
|
|
/// Assigns students to events based on rankings, capacity constraints, and team requirements.
|
|
/// </summary>
|
|
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<EventDefinition> _events;
|
|
private readonly IList<Student> _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<AssignmentRequirement> _assignmentRequirements = [];
|
|
private IList<EventDefinition> _droppedEvents = [];
|
|
private IList<EventDefinition> _includedEvents = [];
|
|
private IList<EventDefinition> _twoTeams = [];
|
|
|
|
/// <summary>
|
|
/// Creates a new event assignment optimizer.
|
|
/// </summary>
|
|
/// <param name="events">The events to assign students to (must not be null or empty)</param>
|
|
/// <param name="students">The students to assign to events (must not be null or empty)</param>
|
|
/// <param name="parameters">Assignment parameters and constraints</param>
|
|
/// <param name="maxSolveTimeSeconds">Maximum solver time in seconds (default: 60.0)</param>
|
|
/// <exception cref="ArgumentNullException">Thrown when events, students, or parameters is null</exception>
|
|
/// <exception cref="ArgumentException">Thrown when collections are empty</exception>
|
|
public EventAssignment(IList<EventDefinition> events, IList<Student> 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));
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Adds a requirement to include or exclude a specific student-event pairing.
|
|
/// </summary>
|
|
/// <param name="assignmentRequirement">The assignment requirement to add</param>
|
|
public void AddAssignmentRequirement(AssignmentRequirement assignmentRequirement)
|
|
{
|
|
_assignmentRequirements.Add(assignmentRequirement);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Marks events to be excluded from the assignment solution.
|
|
/// </summary>
|
|
/// <param name="events">Events to drop from consideration</param>
|
|
public void RemoveEvents(IList<EventDefinition> events)
|
|
{
|
|
_droppedEvents = events;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Forces specific events to be included in the solution.
|
|
/// </summary>
|
|
/// <param name="events">Events that must be included</param>
|
|
public void SetIncludedEvents(IList<EventDefinition> events)
|
|
{
|
|
_includedEvents = events;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Allows specific events to have two teams instead of one.
|
|
/// </summary>
|
|
/// <param name="events">Events that can have two teams</param>
|
|
public void AllowTwoTeams(IList<EventDefinition> events)
|
|
{
|
|
_twoTeams = events;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Solves the event assignment problem using constraint programming.
|
|
/// Assigns students to events based on rankings, capacity constraints, and requirements.
|
|
/// </summary>
|
|
/// <returns>A solution containing team assignments and status</returns>
|
|
public async Task<EventAssignmentSolution> 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;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Extracts the solution from the solver results.
|
|
/// </summary>
|
|
private List<Team> 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();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Objective function: Maximize student event rankings (students get higher-ranked events).
|
|
/// </summary>
|
|
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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Constraint: Each student must have 1-2 regional events.
|
|
/// </summary>
|
|
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));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Constraint: Each student can have at most one individual event.
|
|
/// </summary>
|
|
private void AtMostOneIndividualEvent(CpModel model, BoolVar[,] x)
|
|
{
|
|
AddStudentConstraint(model, x,
|
|
evt => evt.EventFormat == EventFormat.Individual,
|
|
(m, list) => m.AddAtMostOne(list));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Constraint: Each student must have at least one on-site activity.
|
|
/// </summary>
|
|
private void RequireOnSiteActivity(CpModel model, BoolVar[,] x)
|
|
{
|
|
AddStudentConstraint(model, x,
|
|
evt => evt.OnSiteActivity,
|
|
(m, list) => m.AddAtLeastOne(list));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Helper method to add constraints for all students based on event filters.
|
|
/// Reduces code duplication and improves performance by reusing the list buffer.
|
|
/// </summary>
|
|
private void AddStudentConstraint(CpModel model, BoolVar[,] x,
|
|
Func<EventDefinition, bool> eventFilter, Action<CpModel, List<ILiteral>> constraintAction)
|
|
{
|
|
var buffer = new List<ILiteral>();
|
|
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);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Constraint: Individual events can only be assigned if the student ranked them.
|
|
/// </summary>
|
|
private void IndividualEventsMustBeRanked(CpModel model, BoolVar[,] x)
|
|
{
|
|
var prohibitVar = new List<IntVar>(1);
|
|
|
|
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);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Constraint: Each student's total level of effort must be within bounds.
|
|
/// </summary>
|
|
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);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Constraint: Limit the number of events a student is assigned.
|
|
/// </summary>
|
|
private void LimitStudentAssignment(CpModel model, BoolVar[,] x)
|
|
{
|
|
var studentCapacity = new List<IntVar>();
|
|
|
|
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);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Adds event capacity constraints and calculates assignment thresholds.
|
|
/// </summary>
|
|
private List<EventAssignmentThresholds> AddEventAssignmentThresholds(CpModel model, BoolVar[,] x)
|
|
{
|
|
var assignmentThresholdsList = new List<EventAssignmentThresholds>();
|
|
|
|
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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Calculates how many teams should be formed for an event.
|
|
/// </summary>
|
|
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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Calculates the lower and upper bounds for event capacity.
|
|
/// </summary>
|
|
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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Adds capacity constraint for a specific event.
|
|
/// </summary>
|
|
private void AddEventConstraint(CpModel model, BoolVar[,] x, int eventIndex, int lb, int ub)
|
|
{
|
|
var eventCapacity = new List<IntVar>();
|
|
foreach (var s in _allStudents)
|
|
{
|
|
eventCapacity.Add(x[eventIndex, s]);
|
|
}
|
|
|
|
model.AddLinearConstraint(LinearExpr.Sum(eventCapacity), lb, ub);
|
|
model.Minimize(LinearExpr.Sum(eventCapacity));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Creates a threshold object for tracking event assignment metadata.
|
|
/// </summary>
|
|
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]
|
|
};
|
|
}
|
|
|
|
/// <summary>
|
|
/// Adds user-specified assignment requirements (include/exclude student-event pairs).
|
|
/// </summary>
|
|
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]);
|
|
}
|
|
|
|
var prohibitVar = new List<IntVar>(1);
|
|
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);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Calculates coefficients for optimizing student preferences.
|
|
/// Higher-ranked events get higher coefficients.
|
|
/// </summary>
|
|
private static long[] GetEventPickCoefficients(IList<EventDefinition> events, List<StudentEventRanking> eventRankings)
|
|
{
|
|
return events.Select(e =>
|
|
{
|
|
var eventRank = eventRankings.FirstOrDefault(er => er.EventDefinition == e);
|
|
return eventRank == null
|
|
? 0L
|
|
: StudentEventRanking.MaxRank - eventRank.Rank; // Inverse ranking
|
|
}).ToArray();
|
|
}
|
|
}
|
|
}
|