From 7aa47f0e1bb60f2f31347f060cdd31f30de1f088 Mon Sep 17 00:00:00 2001 From: Laurent Perron Date: Thu, 21 Sep 2023 13:05:46 +0200 Subject: [PATCH] add scheduling with circuit sample --- ortools/sat/docs/scheduling.md | 170 +++++++++++++++++++++++++++++++++ 1 file changed, 170 insertions(+) diff --git a/ortools/sat/docs/scheduling.md b/ortools/sat/docs/scheduling.md index 40af95445f..4060d488d3 100644 --- a/ortools/sat/docs/scheduling.md +++ b/ortools/sat/docs/scheduling.md @@ -1340,6 +1340,176 @@ public class RankingSampleSat } ``` +## Ranking tasks in a disjunctive resource with a circuit constraint. + +To rank intervals in a NoOverlap constraint, we will use a circuit constraint to +perform the transitive reduction from precedences to successors. + +This is slightly complicated if some interval variables are optional, and we +need to take into account the case where no task is performed. + +### Python code + +```python +#!/usr/bin/env python3 +"""Code sample to demonstrates how to rank intervals using a circuit.""" + +from ortools.sat.python import cp_model + + +def rank_tasks_with_circuit(model, starts, durations, presences, ranks): + """This method uses a circuit constraints to rank tasks. + + This method assumes that all starts are disjoint, meaning that all tasks have + a strictly positive duration, and they appear in the same NoOverlap + constraint. + + To implement this ranking, we will create a dense graph with num_tasks + 1 + node. + The extra node (with id 0) will be used to decide which tasks is first with + its only outgoing arc, and whhich task is last with its only incoming arc. + Each task i will be associated with id i + 1, and an arc between i + 1 and j + + 1 indicates that j is the immediate successor of i. + + The circuit constraint will insure there is at most 1 hamiltonian path of + length > 1. If no such path exists, then no tasks are active. + + The multiple enforced linear constraints are meant to ensure the compatibility + between the order of starts and the order of ranks, + + Args: + model: The CpModel to add the constraints to. + starts: The array of starts variables of all tasks. + durations: the durations of all tasks. + presences: The array of presence variables of all tasks. + ranks: The array of rank variables of all tasks. + """ + + num_tasks = len(starts) + all_tasks = range(num_tasks) + + arcs = [] + for i in all_tasks: + # if node i is first. + start_lit = model.NewBoolVar(f"start_{i}") + arcs.append((0, i + 1, start_lit)) + model.Add(ranks[i] == 0).OnlyEnforceIf(start_lit) + + # As there are no other constraints on the problem, we can add this + # redundant constraint. + model.Add(starts[i] == 0).OnlyEnforceIf(start_lit) + + # if node i is last. + end_lit = model.NewBoolVar(f"end_{i}") + arcs.append((i + 1, 0, end_lit)) + + for j in all_tasks: + if i == j: + arcs.append((i + 1, i + 1, presences[i].Not())) + model.Add(ranks[i] == -1).OnlyEnforceIf(presences[i].Not()) + else: + literal = model.NewBoolVar(f"arc_{i}_to_{j}") + arcs.append((i + 1, j + 1, literal)) + model.Add(ranks[j] == ranks[i] + 1).OnlyEnforceIf(literal) + + # To perform the transitive reduction from precedences to successors, + # we need to tie the starts of the tasks with 'literal'. + # In a pure problem, the following inequation could be an equality. + # In is not true in general. + # + # Note that we could use this literal to penalize the transition, add an + # extra delay to the precedence. + model.Add(starts[j] >= starts[i] + durations[i]).OnlyEnforceIf(literal) + + # Manage the empty circuit + empty = model.NewBoolVar("empty") + arcs.append((0, 0, empty)) + + for i in all_tasks: + model.AddImplication(empty, presences[i].Not()) + + # Add the circuit constraint. + model.AddCircuit(arcs) + + +def ranking_sample_sat(): + """Ranks tasks in a NoOverlap constraint.""" + + model = cp_model.CpModel() + horizon = 100 + num_tasks = 4 + all_tasks = range(num_tasks) + + starts = [] + durations = [] + intervals = [] + presences = [] + ranks = [] + + # Creates intervals, half of them are optional. + for t in all_tasks: + start = model.NewIntVar(0, horizon, f"start[{t}]") + duration = t + 1 + presence = model.NewBoolVar(f"presence[{t}]") + interval = model.NewOptionalFixedSizeIntervalVar( + start, duration, presence, f"opt_interval[{t}]" + ) + if t < num_tasks // 2: + model.Add(presence == 1) + + starts.append(start) + durations.append(duration) + intervals.append(interval) + presences.append(presence) + + # Ranks = -1 if and only if the tasks is not performed. + ranks.append(model.NewIntVar(-1, num_tasks - 1, f"rank[{t}]")) + + # Adds NoOverlap constraint. + model.AddNoOverlap(intervals) + + # Adds ranking constraint. + rank_tasks_with_circuit(model, starts, durations, presences, ranks) + + # Adds a constraint on ranks. + model.Add(ranks[0] < ranks[1]) + + # Creates makespan variable. + makespan = model.NewIntVar(0, horizon, "makespan") + for t in all_tasks: + model.Add(starts[t] + durations[t] <= makespan).OnlyEnforceIf(presences[t]) + + # Minimizes makespan - fixed gain per tasks performed. + # As the fixed cost is less that the duration of the last interval, + # the solver will not perform the last interval. + model.Minimize(2 * makespan - 7 * sum(presences[t] for t in all_tasks)) + + # Solves the model model. + solver = cp_model.CpSolver() + status = solver.Solve(model) + + if status == cp_model.OPTIMAL: + # Prints out the makespan and the start times and ranks of all tasks. + print(f"Optimal cost: {solver.ObjectiveValue()}") + print(f"Makespan: {solver.Value(makespan)}") + for t in all_tasks: + if solver.Value(presences[t]): + print( + f"Task {t} starts at {solver.Value(starts[t])} " + f"with rank {solver.Value(ranks[t])}" + ) + else: + print( + f"Task {t} in not performed " + f"and ranked at {solver.Value(ranks[t])}" + ) + else: + print(f"Solver exited with nonoptimal status: {status}") + + +ranking_sample_sat() +``` + ## Intervals spanning over breaks in the calendar Sometimes, a task can be interrupted by a break (overnight, lunch break). In