Project scheduling and Solver Foundation revisited, Part IV

In previous posts in this series I described a mixed-integer programming model for resource constrained project scheduling, introduced some project data structures, and wrote a class that encapsulates Solver Foundation code to create and solve the model. Now let’s tie it all together with a short sample program to try it out. First, let’s write a function to generate a random project. CreateProject takes as input a task count and resource count. The specified number of resources are created (all with 100% availability). Then the specified number of tasks are created. Each task has one resource assignment. We cycle through the resources one at a time, and we randomly generate a task duration. Then, we create a few links between the tasks. We don’t do this completely at random, since that may generate a cycle between tasks: we wouldn’t want both a link A –> B and B->A. So we take care always to have links going from lower-numbered tasks to higher-numbered tasks. You can modify this function to generate more interesting random projects if you like.

  class ProjectUtilities {
    // (Replace this with code that creates a "real" project. This project is randomly generated.)
    public static Project CreateProject(int taskCount, int resourceCount) {
      System.Random random = new Random(0);
      int maxDuration = 5;
      int linkCount = Math.Max(taskCount / 5, 1);

      Resource[] resources = new Resource[resourceCount];
      for (int i = 0; i < resources.Length; i++) {
        resources[i] = new Resource("R" + i, 1.0);
      }

      Task[] tasks = new Task[taskCount];
      for (int i = 0; i < tasks.Length; i++) {
        tasks[i] = new Task("t" + i, random.Next(1, maxDuration + 1), new Assignment[] { new Assignment(resources[i % resources.Length], 1.0) });
      }

      TaskDependency[] links = new TaskDependency[linkCount];
      for (int i = 0; i < links.Length; i++) {
        int source = random.Next(0, taskCount - 1);
        int dest = random.Next(source + 1, taskCount); // guaranteeing no cycles.
        links[i] = new TaskDependency { Source = tasks, Destination = tasks[dest] };
      }

      return new Project(tasks, resources, links);
    }
}

Looking at the SchedulingModel.Solve() method from last time, we see that it returns a dictionary that maps task IDs to start times. Let’s write a method that prints out the schedule in an easy-to-read format. PrintProjectSchedule prints the ID, start, and finish for each task.

    public static void PrintProjectSchedule(Project project, IDictionary<int, double> schedule) {
      Console.WriteLine();
      Console.WriteLine("SCHEDULE:");
      foreach (var taskSchedule in schedule) {
        Task task = project.Tasks[taskSchedule.Key];
        double start = Math.Round(taskSchedule.Value, 3);
        Console.WriteLine("{0}: [{1} - {2}]", task.ID, start, start + task.Duration);
      }
    }

The last thing I would like to do is to be able to export my schedule to Microsoft Project. To do this, I can write out the schedule in CSV (comma separated) format, and import into Microsoft Project. Microsoft Project’s CSV format is very easy to figure out – just open Project, and use “Save As” to save a project as CSV (go here for more). Stare at it for a bit and you will get an idea. The only trick is that we need to convert our start and finish times from doubles to DateTimes. We will interpret the doubles as representing the number of working days since the start of the project. Most people work Monday through Friday, so in order to do this conversion we will have to skip weekends. Also we want to honor the standard working hours of 8AM – 5PM. To pull this off I will introduce two more utility routines. If you don’t care about Microsoft Project, you can just copy the code and move on 🙂

    private static DateTime AddDays(DateTime start, double days, bool isStart) {
      while (days > 0) {
        if (days > 1) {
          start = start.AddDays(1);
          start = NextWorkingTime(start, false);
          days -= 1.0;
        }
        else {
          start = start.AddHours(9 * days);
          if (start.TimeOfDay >= TimeSpan.FromHours(isStart ? 17 : 17.01)) {
            TimeSpan duration = start.TimeOfDay - TimeSpan.FromHours(17);
            start = NextWorkingTime(start, isStart).Add(duration);
          }
          days = 0;
        }
      }
      start = NextWorkingTime(start, isStart);
      return start;
    }

    private static DateTime NextWorkingTime(DateTime start, bool isStart) {
      if (start.TimeOfDay >= TimeSpan.FromHours(isStart ? 17 : 17.01)) {
        start = start.Date.AddHours(24 + 8);
      }
      else if (start.Hour < 8) {
        start = start.Date.AddHours(8);
      }

      while (start.DayOfWeek == DayOfWeek.Saturday || start.DayOfWeek == DayOfWeek.Sunday) {
        start = start.AddDays(1);
      }
      return start;
    }

    public static string ToCsv(Project project, IDictionary<int, double> schedule) {
      DateTime projectStart = new DateTime(2011, 2, 1, 8, 0, 0);
      StringBuilder build = new StringBuilder(40 + project.Tasks.Count * 30);
      build.AppendLine("ID,Name,Duration,Start_Date,Finish_Date,Predecessors,Resource_Names");
      Dictionary<string, int[]> depMap = project.Dependencies
        .GroupBy(d => d.Destination.Name)
        .ToDictionary(g => g.Key, g => g.Select(d => d.Source.ID + 1).ToArray()); // need to add 1 for MS Project.
      foreach (Task task in project.Tasks) {
        string predNames = "";
        int[] predIds = null;
        if (depMap.TryGetValue(task.Name, out predIds)) {
          predNames = "\"" + string.Join(",", predIds) + "\"";
        }
        string resourceNames = "\"" + string.Join(",", task.Assignments.Select(a => a.Resource.Name)) + "\"";
        double startDay = schedule[task.ID];
        DateTime start = AddDays(projectStart, startDay, true);
        DateTime finish = AddDays(start, task.Duration, false);
        build.AppendFormat("{0},{1},{2}d,{3},{4},{5},{6}", task.ID + 1, task.Name, task.Duration,
          start, finish, predNames, resourceNames);
        build.AppendLine();
      }
      return build.ToString();
    }
  }

We’re almost done. All we need now is a Main() routine that creates a project, solves it, and prints out the results. Here goes:

using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Text;

namespace ProjectScheduling {
  /// <summary>Driver.
  /// </summary>
  class Program {
    static void Main(string[] args) {
      int taskCount = args.Length > 0 ? Int32.Parse(args[0]) : 5;
      int resourceCount = args.Length > 1 ? Int32.Parse(args[1]) : 2;
      bool verbose = (args.Length > 2 ? Int32.Parse(args[2]) : 0) != 0;

      Project project = ProjectUtilities.CreateProject(taskCount, resourceCount);
      Console.WriteLine(project);

      Stopwatch stopwatch = new Stopwatch();
      SchedulingModel m = new SchedulingModel(verbose);

      stopwatch.Start();
      m.Initialize(project);
      Console.WriteLine("Init time = " + stopwatch.Elapsed);
      IDictionary<int, double> schedule = m.Solve();
      Console.WriteLine("Total time = " + stopwatch.Elapsed);

      ProjectUtilities.PrintProjectSchedule(project, schedule);
      Console.WriteLine(ProjectUtilities.ToCsv(project, schedule));
    }
  }
}

As you can see, you can supply the task count, resource count, and whether to print debug output through the command line. Here is the output for “schedulingmodel 5 2”:

----------------------------------------
PROJECT
TASKS
0: t0 duration = 4
1: t1 duration = 5
2: t2 duration = 4
3: t3 duration = 3
4: t4 duration = 2
LINKS
2 -> 4
RESOURCES
0: R0 max units = 1
1: R1 max units = 1
----------------------------------------

Init time = 00:00:00.2296806
Total time = 00:00:00.8855667

SCHEDULE:
0: [0 - 4]
1: [0 - 5]
2: [4 - 8]
3: [5 - 8]
4: [8 - 10]
ID,Name,Duration,Start_Date,Finish_Date,Predecessors,Resource_Names
1,t0,4d,2/1/2011 8:00:00 AM,2/4/2011 5:00:00 PM,,"R0"
2,t1,5d,2/1/2011 8:00:00 AM,2/7/2011 5:00:00 PM,,"R1"
3,t2,4d,2/7/2011 8:00:00 AM,2/10/2011 5:00:00 PM,,"R0"
4,t3,3d,2/8/2011 8:00:00 AM,2/10/2011 5:00:00 PM,,"R1"
5,t4,2d,2/11/2011 8:00:00 AM,2/14/2011 5:00:00 PM,"3","R0"

The nice thing is that you can copy and paste the bottom part of this output into a CSV file and then view the results in Project.

One last important point. If you enable the debug switch and examine the output from the Gurobi solver, you will notice that typically the solver is able to get a pretty good solution relatively quickly, and then spends a long time proving that the solution is optimal. Here is a typical output log: notice that the incumbent solution goes down to 41 days in only 4 seconds, even though the run takes over 30 seconds.

    Nodes    |    Current Node    |     Objective Bounds      |     Work
 Expl Unexpl |  Obj  Depth IntInf | Incumbent    BestBd   Gap | It/Node Tim

     0     0    0.02924    0  227   48.00000    0.02924   100%     -    0s
H    0     0                      47.0000000    0.02924   100%     -    0s
     0     0    0.17738    0  167   47.00000    0.17738   100%     -    0s
     0     0    0.17835    0  174   47.00000    0.17835   100%     -    0s
     0     0    0.18278    0  168   47.00000    0.18278   100%     -    1s
     0     0    0.18509    0  170   47.00000    0.18509   100%     -    1s
H    0     0                      44.0000000    0.18509   100%     -    1s
     0     0    0.22003    0  246   44.00000    0.22003  99.5%     -    1s
     0     0    0.22474    0  254   44.00000    0.22474  99.5%     -    1s
     0     0    0.22670    0  258   44.00000    0.22670  99.5%     -    1s
     0     0    0.22670    0  267   44.00000    0.22670  99.5%     -    1s
     0     0    0.26301    0  233   44.00000    0.26301  99.4%     -    2s
H    0     0                      42.0000000    0.26301  99.4%     -    2s
     0     0    0.26435    0  262   42.00000    0.26435  99.4%     -    2s
H    0     0                      41.0000000    0.26435  99.4%     -    2s
     0     0    0.26440    0  283   41.00000    0.26440  99.4%     -    2s
     0     0    0.26646    0  272   41.00000    0.26646  99.4%     -    2s
     0     0    0.26695    0  269   41.00000    0.26695  99.3%     -    3s
     0     0    0.26695    0  288   41.00000    0.26695  99.3%     -    3s
     0     0    0.28377    0  314   41.00000    0.28377  99.3%     -    3s
     0     0    0.28476    0  329   41.00000    0.28476  99.3%     -    3s
     0     0    0.28476    0  336   41.00000    0.28476  99.3%     -    3s
     0     0    0.28476    0  276   41.00000    0.28476  99.3%     -    4s
     0     0    0.28476    0  289   41.00000    0.28476  99.3%     -    4s
     0     0    0.28476    0  296   41.00000    0.28476  99.3%     -    4s
     0     0    0.28476    0  260   41.00000    0.28476  99.3%     -    4s
     0     2    0.28476    0  260   41.00000    0.28476  99.3%     -    5s
H   59    39                      40.0000000    0.96732  97.6%  37.9    6s
   822   556    1.50000   16  317   40.00000    1.00000  97.5%  14.4   10s
   884   598    1.66667   24   84   40.00000    1.00000  97.5%  26.2   15s
  1377   841   12.35714   69   39   40.00000    1.00000  97.5%  24.4   20s
  2308  1201   11.00000   44   40   40.00000    1.00000  97.5%  21.2   25s
  2900  1531   15.00000   60   32   40.00000    1.00000  97.5%  18.9   30s

 

You can use the properties on GurobiDirective to terminate earlier if you want. For example, if I want to solve for at most 30 seconds, I can change the first part of Solve() to:

      GurobiDirective directive = new GurobiDirective();
      directive.OutputFlag = verbose;
      directive.TimeLimit = (int)TimeSpan.FromSeconds(30).TotalMilliseconds;

GurobiDirective has lots of options for controlling the solver behavior: check the documentation for details.

Author: natebrix

Follow me on twitter at @natebrix.

2 thoughts on “Project scheduling and Solver Foundation revisited, Part IV”

  1. Dear Nathan, 3 years ago I have decided to stop using OML language, because I could not model RCPSP scheduling problem. Up to now, even reading your posts, I dont have a clue how have you handled a resources capacity constraint when a start time of a given task is yet unknown.

    1. Hi there – if you look at the paper I referenced in the first post in the series (http://hal.archives-ouvertes.fr/docs/00/36/13/95/PDF/Soumission-COR.pdf), you will see that resource capacity constraints are handled in equation (2), which is implemented by the code. The idea is to simply make sure that the resource usage for each time period does not exceed the capacity. The resource capacity is calculated through other constraints that relate to the schedules for the project tasks.

Leave a comment