'Optimized Constraint scheduling with Google OR Tools slow and not finding a solution

Our team is trying to do constraint scheduling, requiring the model to fill in who will do which tasks on which day. We started from the https://developers.google.com/optimization/scheduling/employee_scheduling example. We succeeded in scheduling simple, obvious things eg assigning a task after another task, but the moment we increase complexity or size the model is always unable to provide a feasible solution. We tried modelling the constraints differently in the ModelWaitingForConstraintsOptimized method. But to no avail.

using Binocs.Scheduling.AI.Host.Data;
using Binocs.Scheduling.AI.Host.Domain
using Google.OrTools.Sat;
using Constraint = Binocs.Scheduling.AI.Host.Domain.Constraint;

namespace Binocs.Scheduling.AI.Host;

public class Program
{

    public static void Main()
    {
        var scheduleItems = new ScheduleItemQuery().Query().ToArray();
        var teamMembers = (new TeamMembersQuery().Query()).ToDictionary(_ => _.Id);
        var equipments = (new EquipmentsQuery().Query()).ToDictionary(_ => _.Id);
        var teamMemberAvailabilities = new TeamMembersAvailabilitiesQuery().Query(teamMembers.Keys);
        var equipmentAvailabilities = new EquipmentAvailabilitiesQuery().Query(equipments.Keys);
        var constraints = (new ConstraintsQuery().Query()).GroupBy(_ => _.From).ToDictionary(_ => _.Key, _ => _.ToArray());

        #region AIData
        var horizon = new Horizon(8);

        var time = new Time(DateTime.Today);

        var aiScheduleItemFactory = new AIScheduleItemFactory(time);
        var aiAvailabilityFactory = new AIAvailabilityFactory(time);
        var aiScheduledItemFactory = new AIScheduledItemFactory(time);

        var aiItems = scheduleItems.Select(aiScheduleItemFactory.Create).ToArray();
        var aiScheduledItems = scheduleItems.Select(aiScheduledItemFactory.Create).ToArray();

        var allAIAvailabilities = teamMemberAvailabilities.Select(aiAvailabilityFactory.Create)
            .Concat(equipmentAvailabilities.Select(aiAvailabilityFactory.Create)).ToArray();

        var availabilitiesPerResource = allAIAvailabilities
            .Where(_ => _.Offset >= 0 && horizon.WithinHorizon(_.Offset))
            .GroupBy(_ => _.Id)
            .OrderBy(_ => _.Key)
            .ToArray();

        var horizonCompensatedAvailabilities = availabilitiesPerResource.ToDictionary(_ => _.Key, _ =>
        {
            var arr = horizon.EmptyHorizon();

            foreach (var aiTeamMemberAvailability in _)
            {
                arr[aiTeamMemberAvailability.Offset] = aiTeamMemberAvailability.Hours;
            }

            return arr;
        });

        var taskSizes = aiItems.Select(_ => _.PlannedHours).ToArray();
        var dueIntervals = aiItems.Select(_ => _.Deadline).ToArray();
        var allAvailabilities = horizonCompensatedAvailabilities.Select(_ => _.Value).ToArray();

        var totalDistinctWorkers = allAvailabilities.Length;
        var totalDistinctTasks = taskSizes.Length;
        var totalDistinctIntervals = horizon.Intervals;

        var indexedDistinctWorkers = Enumerable.Range(0, totalDistinctWorkers).ToArray();
        var indexedDistinctTasks = Enumerable.Range(0, totalDistinctTasks).ToArray();
        var indexedDistinctIntervals = Enumerable.Range(0, totalDistinctIntervals).ToArray();
        #endregion

        #region AIModel

        var model = new CpModel();
        var allVariablesTheAINeedsToFillIn = new IntVar[totalDistinctWorkers, totalDistinctTasks, totalDistinctIntervals];
        var flattenedAllVariablesTheAINeedsToFillIn = new IntVar[totalDistinctWorkers * totalDistinctTasks * totalDistinctIntervals];

        ModelCompetences(indexedDistinctWorkers, indexedDistinctTasks, indexedDistinctIntervals, allAIAvailabilities, scheduleItems, allVariablesTheAINeedsToFillIn, model, totalDistinctWorkers, totalDistinctTasks, flattenedAllVariablesTheAINeedsToFillIn);

        ModelAvailabilities(indexedDistinctIntervals, indexedDistinctWorkers, totalDistinctTasks, indexedDistinctTasks, allVariablesTheAINeedsToFillIn, model, taskSizes, allAvailabilities);

        ModelAssignments(indexedDistinctTasks, totalDistinctWorkers, totalDistinctIntervals, indexedDistinctWorkers, indexedDistinctIntervals, allVariablesTheAINeedsToFillIn, model);

        var taskIntervals = new IntVar[totalDistinctTasks];

        ModelDates(indexedDistinctTasks, taskIntervals, model, totalDistinctIntervals, indexedDistinctWorkers, indexedDistinctIntervals, allVariablesTheAINeedsToFillIn);

        ModelWaitingForConstraints(scheduleItems, constraints, model, taskIntervals);
        //ModelWaitingForConstraintsOptimized(scheduleItems, constraints, model, horizon, indexedDistinctWorkers, flattenedAllVariablesTheAINeedsToFillIn);

        ModelEquipmentAtSameTimeAsTeammember(scheduleItems, model, taskIntervals);

        ModelObjective(flattenedAllVariablesTheAINeedsToFillIn, indexedDistinctWorkers, indexedDistinctTasks, indexedDistinctIntervals, totalDistinctWorkers, totalDistinctTasks, dueIntervals, taskSizes, model);

        #endregion

        #region Solve
        var solver = new CpSolver();
        var status = solver.Solve(model);
        Console.WriteLine($"Solve status: {status}");
        if (status == CpSolverStatus.Optimal || status == CpSolverStatus.Feasible)
        {
            var resourceLookup = availabilitiesPerResource.Select(_ => _.First()).ToArray();
            Console.WriteLine($"Total cost: {solver.ObjectiveValue}\n");
            foreach (var interval in indexedDistinctIntervals)
            {
                foreach (var worker in indexedDistinctWorkers)
                {
                    foreach (var task in indexedDistinctTasks)
                    {
                        if (solver.Value(allVariablesTheAINeedsToFillIn[worker, task, interval]) > 0.5)
                        {
                            var late = interval > dueIntervals[task] ? "L" : "T";
                            aiScheduledItems[task].WithPlanned(interval);
                            aiScheduledItems[task].WithResource(resourceLookup[worker].Id);
                            Console.WriteLine($"{late}{taskSizes[task]}  interval {interval} - Due {dueIntervals[task]} - Size {taskSizes[task]} - Worker {(resourceLookup[worker].ForEquipment ? equipments[resourceLookup[worker].Id].Code : teamMembers[resourceLookup[worker].Id].Code)} assigned to task {scheduleItems[task].ScheduleItemId} - {scheduleItems[task].ServiceItemActivityId} {(scheduleItems[task].EquipmentPlannedHours == 0m ? "" : "EQ")}");
                        }
                    }
                }
            }
        }
        else
        {
            Console.WriteLine("No solution found.");
        }
        Console.WriteLine("Statistics");
        Console.WriteLine($"  - conflicts : {solver.NumConflicts()}");
        Console.WriteLine($"  - branches  : {solver.NumBranches()}");
        Console.WriteLine($"  - wall time : {solver.WallTime()}s");
        #endregion

    }

    #region ModelMethods
    static void ModelCompetences(int[] indexedDistinctWorkers, int[] indexedDistinctTasks, int[] indexedDistinctIntervals,
        AIAvailability[] allAiAvailabilities, ScheduleItem[] scheduleItems, IntVar[,,] allVariablesTheAiNeedsToFillIn,
        CpModel cpModel, int totalDistinctWorkers, int totalDistinctTasks,
        IntVar[] flattenedAllVariablesTheAiNeedsToFillIn)
    {
        foreach (var worker in indexedDistinctWorkers)
        {
            foreach (var task in indexedDistinctTasks)
            {
                foreach (var interval in indexedDistinctIntervals)
                {
                    if (allAiAvailabilities[worker].IsCompetentFor(scheduleItems[task]))
                    {
                        allVariablesTheAiNeedsToFillIn[worker, task, interval] =
                            cpModel.NewBoolVar($"allVariablesTheAINeedsToFillIn[{worker},{task},{interval}]");
                    }
                    else
                    {
                        allVariablesTheAiNeedsToFillIn[worker, task, interval] =
                            cpModel.NewConstant(0, $"allVariablesTheAINeedsToFillIn[{worker},{task},{interval}]");
                    }

                    var flattenedIndex =
                        MatrixOperations.Flatten(worker, task, interval, totalDistinctWorkers, totalDistinctTasks);
                    flattenedAllVariablesTheAiNeedsToFillIn[flattenedIndex] =
                        allVariablesTheAiNeedsToFillIn[worker, task, interval];
                }
            }
        }
    }

    static void ModelAvailabilities(int[] indexedDistinctIntervals, int[] indexedDistinctWorkers, int totalDistinctTasks, int[] indexedDistinctTasks,
        IntVar[,,] intVars, CpModel model, int[] taskSizes, int[][] allAvailabilities)
    {
        foreach (var interval in indexedDistinctIntervals)
        {
            foreach (var worker in indexedDistinctWorkers)
            {
                var vars = new IntVar[totalDistinctTasks];
                foreach (var task in indexedDistinctTasks)
                {
                    vars[task] = intVars[worker, task, interval];
                }

                model.Add(LinearExpr.ScalProd(vars, taskSizes) <= allAvailabilities[worker][interval]);
            }
        }
    }

    static void ModelAssignments(int[] totalDistinctTasks, int totalDistinctWorkers, int totalDistinctIntervals,
        int[] indexedDistinctWorkers, int[] indexedDistinctIntervals, IntVar[,,] allVariablesTheAiNeedsToFillIn,
        CpModel model)
    {
        foreach (var task in totalDistinctTasks)
        {
            var vars = new IntVar[totalDistinctWorkers * totalDistinctIntervals];
            foreach (var worker in indexedDistinctWorkers)
            {
                foreach (var interval in indexedDistinctIntervals)
                {
                    vars[MatrixOperations.Flatten(worker, interval, totalDistinctWorkers)] = allVariablesTheAiNeedsToFillIn[worker, task, interval];
                }
            }

            model.Add(LinearExpr.Sum(vars) == 1);
        }
    }

    static void ModelWaitingForConstraints(ScheduleItem[] scheduleItems, Dictionary<int, Constraint[]> constraintsMap, CpModel model,
        IntVar[] taskIntervals)
    {
        for (var i = 0; i < scheduleItems.Length; i++)
        {
            var current = scheduleItems[i];
            if (!constraintsMap.TryGetValue(current.ServiceItemActivityId, out var applicableConstraints)) continue;
            foreach (var c in applicableConstraints)
            {
                var related = scheduleItems.Select((si, i) => new { si, i }).Where(_ =>
                    _.si.ScheduleItemId == current.ScheduleItemId && _.si.ServiceItemActivityId == c.To);
                foreach (var otherSi in related)
                {
                    switch (c.WaitingForType)
                    {
                        case 0:
                            model.Add(taskIntervals[i] >= taskIntervals[otherSi.i] + c.Value);
                            break;
                        case 1:
                            model.Add(taskIntervals[i] == taskIntervals[otherSi.i] + c.Value);
                            break;
                        case 2:
                            model.Add(taskIntervals[i] <= taskIntervals[otherSi.i] + c.Value);
                            break;
                    }
                }
            }
        }
    }

    static void ModelWaitingForConstraintsOptimized(ScheduleItem[] scheduleItems, Dictionary<int, Constraint[]> constraintsMap, CpModel model, Horizon horizon, int[] indexedDistinctWorkers, IntVar[] flattenedAllVariablesTheAINeedsToFillIn)
    {
        for (var scheduleItemIndex = 0; scheduleItemIndex < scheduleItems.Length; scheduleItemIndex++)
        {
            var current = scheduleItems[scheduleItemIndex];
            if (!constraintsMap.TryGetValue(current.ServiceItemActivityId, out var applicableConstraints)) continue;
            foreach (var c in applicableConstraints)
            {
                var related = scheduleItems.Select((si, i) => new { si, i }).Where(_ =>
                      _.si.ScheduleItemId == current.ScheduleItemId && _.si.ServiceItemActivityId == c.To).ToList();
                for (var horizonDayIndex = 0; horizonDayIndex < horizon.Value.Length; horizonDayIndex++)
                {
                    for (var workerIndex = 0; workerIndex < indexedDistinctWorkers.Length; workerIndex++)
                    {
                        var d1 =
                            MatrixOperations.Flatten(workerIndex, scheduleItemIndex, horizonDayIndex, indexedDistinctWorkers.Length, scheduleItems.Length);

                        foreach (var otherSi in related)
                        {
                            switch (c.WaitingForType)
                            {
                                case 0:
                                    var endDayAtLeast = Math.Min(horizonDayIndex + c.Value, horizon.Value.Length - 1);
                                    for (var day = 0; day < endDayAtLeast; day++)
                                    {
                                        var d2Atleast =
                                            MatrixOperations.Flatten(workerIndex, otherSi.i, day, indexedDistinctWorkers.Length, scheduleItems.Length);
                                        model.AddImplication(flattenedAllVariablesTheAINeedsToFillIn[d1], flattenedAllVariablesTheAINeedsToFillIn[d2Atleast].Not());
                                    }
                                    break;
                                case 1:
                                    var exactDay = horizonDayIndex + c.Value;
                                    if (exactDay > horizon.Value.Length - 1) break;
                                    var d2Exact =
                                        MatrixOperations.Flatten(workerIndex, otherSi.i, exactDay, indexedDistinctWorkers.Length, scheduleItems.Length);
                                    model.AddImplication(flattenedAllVariablesTheAINeedsToFillIn[d1], flattenedAllVariablesTheAINeedsToFillIn[d2Exact]);
                                    break;
                                case 2:
                                    var startDayAtMost = horizonDayIndex + c.Value;
                                    var endDayAtMost = horizon.Value.Length;
                                    for (var day = startDayAtMost; day < endDayAtMost; day++)
                                    {
                                        var d2AtMost =
                                            MatrixOperations.Flatten(workerIndex, otherSi.i, day, indexedDistinctWorkers.Length, scheduleItems.Length);
                                        model.AddImplication(flattenedAllVariablesTheAINeedsToFillIn[d1], flattenedAllVariablesTheAINeedsToFillIn[d2AtMost].Not());
                                    }
                                    break;
                            }
                        }
                    }
                }

            }
        }
    }

    static void ModelEquipmentAtSameTimeAsTeammember(ScheduleItem[] scheduleItems, CpModel model, IntVar[] taskIntervals)
    {
        foreach (var g in scheduleItems.Select((si, i) => new { si, i })
                     .GroupBy(_ => new { _.si.ScheduleItemId, _.si.ServiceItemActivityId }).Where(_ => _.Count() > 1))
        {
            model.Add(taskIntervals[g.First().i] == taskIntervals[g.Skip(1).First().i]);
        }
    }

    static void ModelDates(int[] indexedDistinctTasks, IntVar[] taskIntervals, CpModel model, int totalDistinctIntervals,
        int[] indexedDistinctWorkers, int[] indexedDistinctIntervals, IntVar[,,] allVariablesTheAiNeedsToFillIn)
    {
        foreach (var task in indexedDistinctTasks)
        {
            taskIntervals[task] = model.NewIntVar(0, totalDistinctIntervals, "taskInterval");
            foreach (var worker in indexedDistinctWorkers)
            {
                foreach (var interval in indexedDistinctIntervals)
                {
                    model.Add(taskIntervals[task] == interval).OnlyEnforceIf(allVariablesTheAiNeedsToFillIn[worker, task, interval]);
                }
            }
        }
    }

    static void ModelObjective(IntVar[] flattenedAllVariablesTheAiNeedsToFillIn, int[] indexedDistinctWorkers, int[] indexedDistinctTasks,
        int[] indexedDistinctIntervals, int totalDistinctWorkers, int totalDistinctTasks, int[] dueIntervals, int[] taskSizes,
        CpModel model)
    {
        var xDuePassedFlat = Enumerable.Range(0, flattenedAllVariablesTheAiNeedsToFillIn.Length).ToArray();
        foreach (var worker in indexedDistinctWorkers)
        {
            foreach (var task in indexedDistinctTasks)
            {
                foreach (var interval in indexedDistinctIntervals)
                {
                    xDuePassedFlat[MatrixOperations.Flatten(worker, task, interval, totalDistinctWorkers, totalDistinctTasks)] = Math.Max(0, (interval - dueIntervals[task])) - dueIntervals[task];
                }
            }
        }

        model.Minimize(LinearExpr.ScalProd(flattenedAllVariablesTheAiNeedsToFillIn, xDuePassedFlat));
    }
    #endregion
}


Solution 1:[1]

just try

solver.StringParameters = "num_search_workers:8,log_seach_progress:true";

Furthermore, this example uses the assumptions that num_shift_types == num_nurses. If this is false, there are no solutions.

When I run the model you sent me, I get:

Starting Search at 14.24s with 16 workers.
10 full subsolvers: [default_lp, no_lp, max_lp, core, reduced_costs, pseudo_costs, quick_restart, quick_restart_no_lp, lb_tree_search, probing]
Interleaved subsolvers: [feasibility_pump, rnd_var_lns_default, rnd_cst_lns_default, graph_var_lns_default, graph_cst_lns_default, rins_lns_default, rens_lns_default]
#Bound  17.99s best:inf   next:[-91170,1793684] am1_presolve num_literals:163919 num_am1:129 increase:357955 work_done:100382751
#1      18.23s best:-514  next:[-91170,-515] core fixed_bools:63/171500
#Bound  18.24s best:-514  next:[-90570,-515] bool_core num_cores:0 [] assumptions:139518 depth:0 fixed_bools:63/171500
#2      18.30s best:-1043 next:[-90570,-1044] core fixed_bools:83/171500
#Bound  18.31s best:-1043 next:[-90370,-1044] bool_core num_cores:0 [] assumptions:139498 depth:0 fixed_bools:83/171500
#3      18.36s best:-1504 next:[-90370,-1505] quick_restart_no_lp fixed_bools:185/171371
#Bound  18.62s best:-1504 next:[-2305,-1505] probing initial_propagation
#Bound  20.50s best:-1504 next:[-1880,-1505] probing
#4      21.48s best:-1593 next:[-1880,-1594] graph_cst_lns_default(d=0.29 s=219 t=0.10 p=0.00)
#Bound  22.11s best:-1593 next:[-1600,-1594] max_lp
#5      22.38s best:-1594 next:[-1600,-1595] graph_cst_lns_default(d=0.28 s=224 t=0.10 p=0.33)
#6      24.04s best:-1595 next:[-1600,-1596] graph_var_lns_default(d=0.94 s=233 t=0.10 p=1.00)
#7      25.90s best:-1596 next:[-1600,-1597] rnd_var_lns_default(d=0.57 s=246 t=0.10 p=0.50)
#8      26.53s best:-1597 next:[-1600,-1598] graph_cst_lns_default(d=0.53 s=249 t=0.10 p=0.57)
#9      28.21s best:-1598 next:[-1600,-1599] rnd_var_lns_default(d=0.75 s=256 t=0.10 p=0.60)
#10     28.45s best:-1599 next:[-1600,-1600] rnd_cst_lns_default(d=0.58 s=257 t=0.10 p=0.56)
#11     30.27s best:-1600 next:[]         graph_cst_lns_default(d=0.80 s=259 t=0.10 p=0.70)
#Done   30.28s core
#Done   30.31s probing
#Done   30.45s pseudo_costs

...

CpSolverResponse summary:
status: OPTIMAL
objective: -1600
best_bound: -1600
booleans: 171371
conflicts: 0
branches: 10123
propagations: 10736218
integer_propagations: 10978261
restarts: 10077
lp_iterations: 24982
walltime: 31.5911
usertime: 252.682
deterministic_time: 67.9784
gap_integral: 7.71199

For a more general problem, you should look at the shift scheduling example

Sources

This article follows the attribution requirements of Stack Overflow and is licensed under CC BY-SA 3.0.

Source: Stack Overflow

Solution Source
Solution 1