@@ -2609,4 +2609,265 @@ def transitions_in_no_overlap_sample_sat():
26092609transitions_in_no_overlap_sample_sat()
26102610```
26112611
2612+ ## Managing sequences in a no_overlap constraint
2613+
2614+ In some scheduling problems, there can be constraints on sequence of tasks. For
2615+ instance, tasks of a given type may have limit on any contiguous constraints.
2616+
2617+ The circuit constraint is used to maintain the current length of any sequence.
2618+
2619+ ### Python code
2620+
2621+ ``` python
2622+ # !/usr/bin/env python3
2623+ """ Implements sequence constraints in a no_overlap constraint."""
2624+
2625+ from typing import Dict, List, Sequence, Tuple
2626+
2627+ from ortools.sat.python import cp_model
2628+
2629+
2630+ def sequence_constraints_with_circuit (
2631+ model : cp_model.CpModel,
2632+ starts : Sequence[cp_model.IntVar],
2633+ durations : Sequence[int ],
2634+ task_types : Sequence[str ],
2635+ lengths : Sequence[cp_model.IntVar],
2636+ cumuls : Sequence[cp_model.IntVar],
2637+ sequence_length_constraints : Dict[str , Tuple[int , int ]],
2638+ sequence_cumul_constraints : Dict[str , Tuple[int , int , int ]],
2639+ ) -> Sequence[Tuple[cp_model.IntVar, int ]]:
2640+ """ This method uses a circuit constraint to rank tasks.
2641+
2642+ This method assumes that all starts are disjoint, meaning that all tasks have
2643+ a strictly positive duration, and they appear in the same NoOverlap
2644+ constraint.
2645+
2646+ The extra node (with id 0) will be used to decide which task is first with
2647+ its only outgoing arc, and which task is last with its only incoming arc.
2648+ Each task i will be associated with id i + 1, and an arc between i + 1 and j +
2649+ 1 indicates that j is the immediate successor of i.
2650+
2651+ The circuit constraint ensures there is at most 1 hamiltonian cycle of
2652+ length > 1. If no such path exists, then no tasks are active.
2653+ We also need to enforce that any hamiltonian cycle of size > 1 must contain
2654+ the node 0. And thus, there is a self loop on node 0 iff the circuit is empty.
2655+
2656+ Args:
2657+ model: The CpModel to add the constraints to.
2658+ starts: The array of starts variables of all tasks.
2659+ durations: the durations of all tasks.
2660+ task_types: The type of all tasks.
2661+ lengths: The computed length of the current sequence for each task.
2662+ cumuls: The computed cumul of the current sequence for each task.
2663+ sequence_length_constraints: the array of tuple (`task_type`,
2664+ (`length_min`, `length_max`)) that specifies the minimum and maximum
2665+ length of the sequence of tasks of type `task_type`.
2666+ sequence_cumul_constraints: the array of tuple (`task_type`,
2667+ (`soft_max`, `linear_penalty`, `hard_max`)) that specifies that if the
2668+ cumul of the sequence of tasks of type `task_type` is greater than
2669+ `soft_max`, then `linear_penalty` must be added to the cost
2670+
2671+ Returns:
2672+ The list of pairs (Boolean variables, penalty) to be added to the objective.
2673+ """
2674+
2675+ num_tasks = len (starts)
2676+ all_tasks = range (num_tasks)
2677+
2678+ arcs: List[cp_model.ArcT] = []
2679+ penalty_terms = []
2680+ for i in all_tasks:
2681+ # if node i is first.
2682+ start_lit = model.new_bool_var(f " start_ { i} " )
2683+ arcs.append((0 , i + 1 , start_lit))
2684+ model.add(lengths[i] == 1 ).only_enforce_if(start_lit)
2685+ model.add(cumuls[i] == durations[i]).only_enforce_if(start_lit)
2686+
2687+ # As there are no other constraints on the problem, we can add this
2688+ # redundant constraint.
2689+ model.add(starts[i] == 0 ).only_enforce_if(start_lit)
2690+
2691+ # if node i is last.
2692+ end_lit = model.new_bool_var(f " end_ { i} " )
2693+ arcs.append((i + 1 , 0 , end_lit))
2694+
2695+ # Penalize the cumul of the last task w.r.t. the soft max
2696+ soft_max, linear_penalty, hard_max = sequence_cumul_constraints[task_types[i]]
2697+ if soft_max < hard_max:
2698+ aux = model.new_int_var(0 , hard_max - soft_max, f " aux_ { i} " )
2699+ model.add_max_equality(aux, [0 , cumuls[i] - soft_max])
2700+
2701+ excess = model.new_int_var(0 , hard_max - soft_max, f " excess_ { i} " )
2702+ model.add(excess == aux).only_enforce_if(end_lit)
2703+ model.add(excess == 0 ).only_enforce_if(~ end_lit)
2704+ penalty_terms.append((excess, linear_penalty))
2705+
2706+ for j in all_tasks:
2707+ if i == j:
2708+ continue
2709+ lit = model.new_bool_var(f " arc_ { i} _to_ { j} " )
2710+ arcs.append((i + 1 , j + 1 , lit))
2711+
2712+ # To perform the transitive reduction from precedences to successors,
2713+ # we need to tie the starts of the tasks with 'literal'.
2714+ # In a non pure problem, the following equality must be an inequality.
2715+ model.add(starts[j] == starts[i] + durations[i]).only_enforce_if(lit)
2716+
2717+ # We add the constraint to incrementally maintain the length and the cumul
2718+ # variables of the sequence.
2719+ if task_types[i] == task_types[j]: # Same task type.
2720+ # Increase the length of the sequence by 1.
2721+ model.add(lengths[j] == lengths[i] + 1 ).only_enforce_if(lit)
2722+
2723+ # Make sure the length of the sequence is within the bounds.
2724+ length_max = sequence_length_constraints[task_types[j]][1 ]
2725+ model.add(lengths[j] <= length_max).only_enforce_if(lit)
2726+
2727+ # Increase the cumul of the sequence by the duration of the task.
2728+ model.add(cumuls[j] == cumuls[i] + durations[j]).only_enforce_if(lit)
2729+
2730+ # Make sure the cumul of the sequence is within the bounds.
2731+ cumul_hard_max = sequence_cumul_constraints[task_types[j]][2 ]
2732+ model.add(cumuls[j] <= cumul_hard_max).only_enforce_if(lit)
2733+
2734+ else : # Switching task type.
2735+ # Reset the length to 1.
2736+ model.add(lengths[j] == 1 ).only_enforce_if(lit)
2737+
2738+ # Make sure the previous length is within bounds.
2739+ length_min = sequence_length_constraints[task_types[i]][0 ]
2740+ model.add(lengths[i] >= length_min).only_enforce_if(lit)
2741+
2742+ # Reset the cumul to the duration of the task.
2743+ model.add(cumuls[j] == durations[j]).only_enforce_if(lit)
2744+
2745+ # Penalize the cumul of the previous task w.r.t. the soft max
2746+ if soft_max < hard_max:
2747+ aux = model.new_int_var(0 , hard_max - soft_max, f " aux_ { i} " )
2748+ model.add_max_equality(aux, [0 , cumuls[i] - soft_max])
2749+
2750+ excess = model.new_int_var(0 , hard_max - soft_max, f " excess_ { i} " )
2751+ model.add(excess == aux).only_enforce_if(lit)
2752+ model.add(excess == 0 ).only_enforce_if(~ lit)
2753+ penalty_terms.append((excess, linear_penalty))
2754+
2755+ # Add the circuit constraint.
2756+ model.add_circuit(arcs)
2757+
2758+ return penalty_terms
2759+
2760+
2761+ def sequences_in_no_overlap_sample_sat ():
2762+ """ Implement cumul and length constraints in a NoOverlap constraint."""
2763+
2764+ # Tasks (duration, type).
2765+ tasks = [
2766+ (5 , " A" ),
2767+ (6 , " A" ),
2768+ (7 , " A" ),
2769+ (2 , " A" ),
2770+ (3 , " A" ),
2771+ (5 , " B" ),
2772+ (2 , " B" ),
2773+ (3 , " B" ),
2774+ (1 , " B" ),
2775+ (4 , " B" ),
2776+ (3 , " B" ),
2777+ (6 , " B" ),
2778+ (2 , " B" ),
2779+ ]
2780+
2781+ # Sequence length constraints on task_types: (hard_min, hard_max)
2782+ sequence_length_constraints = {
2783+ " A" : (1 , 3 ),
2784+ " B" : (2 , 2 ),
2785+ }
2786+
2787+ # Sequence cumul constraints on task_types:
2788+ # (soft_max, linear_penalty, hard_max)
2789+ sequence_cumul_constraints = {
2790+ " A" : (6 , 1 , 10 ),
2791+ " B" : (7 , 0 , 7 ),
2792+ }
2793+
2794+ model: cp_model.CpModel = cp_model.CpModel()
2795+ horizon: int = sum (t[0 ] for t in tasks)
2796+
2797+ num_tasks = len (tasks)
2798+ all_tasks = range (num_tasks)
2799+
2800+ starts = []
2801+ durations = []
2802+ intervals = []
2803+ task_types = []
2804+
2805+ # Creates intervals for each task.
2806+ for duration, task_type in tasks:
2807+ index = len (starts)
2808+ start = model.new_int_var(0 , horizon - duration, f " start[ { index} ] " )
2809+ interval = model.new_fixed_size_interval_var(
2810+ start, duration, f " interval[ { index} ] "
2811+ )
2812+
2813+ starts.append(start)
2814+ durations.append(duration)
2815+ intervals.append(interval)
2816+ task_types.append(task_type)
2817+
2818+ # Create length variables for each task.
2819+ lengths = []
2820+ max_length = max (c[1 ] for c in sequence_length_constraints.values())
2821+ for i in all_tasks:
2822+ lengths.append(model.new_int_var(0 , max_length, f " length_ { i} " ))
2823+
2824+ # Create cumul variables for each task.
2825+ cumuls = []
2826+ max_cumul = max (c[2 ] for c in sequence_cumul_constraints.values())
2827+ for i in all_tasks:
2828+ cumuls.append(model.new_int_var(0 , max_cumul, f " cumul_ { i} " ))
2829+
2830+ # Adds NoOverlap constraint.
2831+ model.add_no_overlap(intervals)
2832+
2833+ # Adds the constraints on the partial lengths and cumuls of the sequence of
2834+ # tasks.
2835+ penalty_terms = sequence_constraints_with_circuit(
2836+ model,
2837+ starts,
2838+ durations,
2839+ task_types,
2840+ lengths,
2841+ cumuls,
2842+ sequence_length_constraints,
2843+ sequence_cumul_constraints,
2844+ )
2845+
2846+ # Minimize the sum of penalties,
2847+ model.minimize(sum (var * penalty for var, penalty in penalty_terms))
2848+
2849+ # Solves the model model.
2850+ solver = cp_model.CpSolver()
2851+ status = solver.solve(model)
2852+
2853+ if status == cp_model.OPTIMAL or status == cp_model.FEASIBLE :
2854+ # Prints out the makespan and the start times and ranks of all tasks.
2855+ print (f " Optimal cost: { solver.objective_value} " )
2856+ to_sort = []
2857+ for t in all_tasks:
2858+ to_sort.append((solver.value(starts[t]), t))
2859+ to_sort.sort()
2860+ for start, t in to_sort:
2861+ print (
2862+ f " Task { t} of type { task_types[t]} with duration "
2863+ f " { durations[t]} starts at { start} , length = "
2864+ f " { solver.value(lengths[t])} , cumul = { solver.value(cumuls[t])} "
2865+ )
2866+ else :
2867+ print (f " Solver exited with nonoptimal status: { status} " )
2868+
2869+
2870+ sequences_in_no_overlap_sample_sat()
2871+ ```
2872+
26122873## Convex hull of a set of intervals
0 commit comments