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();
}
}
}