Skip to content

Commit 4455ecf

Browse files
committed
[CP-SAT] add new scheduling example; improve hint preservation; add rare crash in presolve
1 parent a79c10d commit 4455ecf

File tree

6 files changed

+585
-26
lines changed

6 files changed

+585
-26
lines changed

ortools/sat/cp_model_presolve.cc

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2278,8 +2278,8 @@ bool CpModelPresolver::RemoveSingletonInLinear(ConstraintProto* ct) {
22782278
// Just fix everything.
22792279
context_->UpdateRuleStats("independent linear: solved by DP");
22802280
for (int i = 0; i < num_vars; ++i) {
2281-
if (!context_->IntersectDomainWith(ct->linear().vars(i),
2282-
Domain(result.solution[i]))) {
2281+
if (!context_->IntersectDomainWithAndUpdateHint(
2282+
ct->linear().vars(i), Domain(result.solution[i]))) {
22832283
return false;
22842284
}
22852285
}
@@ -2291,7 +2291,8 @@ bool CpModelPresolver::RemoveSingletonInLinear(ConstraintProto* ct) {
22912291
if (ct->enforcement_literal().size() == 1) {
22922292
indicator = ct->enforcement_literal(0);
22932293
} else {
2294-
indicator = context_->NewBoolVar("indicator");
2294+
indicator =
2295+
context_->NewBoolVarWithConjunction(ct->enforcement_literal());
22952296
auto* new_ct = context_->working_model->add_constraints();
22962297
*new_ct->mutable_enforcement_literal() = ct->enforcement_literal();
22972298
new_ct->mutable_bool_or()->add_literals(indicator);
@@ -2302,12 +2303,16 @@ bool CpModelPresolver::RemoveSingletonInLinear(ConstraintProto* ct) {
23022303
costs[i] > 0 ? domains[i].Min() : domains[i].Max();
23032304
const int64_t other_value = result.solution[i];
23042305
if (best_value == other_value) {
2305-
if (!context_->IntersectDomainWith(ct->linear().vars(i),
2306-
Domain(best_value))) {
2306+
if (!context_->IntersectDomainWithAndUpdateHint(
2307+
ct->linear().vars(i), Domain(best_value))) {
23072308
return false;
23082309
}
23092310
continue;
23102311
}
2312+
context_->UpdateVarSolutionHint(
2313+
ct->linear().vars(i), context_->LiteralSolutionHint(indicator)
2314+
? other_value
2315+
: best_value);
23112316
if (RefIsPositive(indicator)) {
23122317
if (!context_->StoreAffineRelation(ct->linear().vars(i), indicator,
23132318
other_value - best_value,
@@ -11763,7 +11768,8 @@ void CpModelPresolver::ProcessVariableOnlyUsedInEncoding(int var) {
1176311768
int64_t value1, value2;
1176411769
if (cost == 0) {
1176511770
context_->UpdateRuleStats("variables: fix singleton var in linear1");
11766-
return (void)context_->IntersectDomainWith(var, Domain(implied.Min()));
11771+
return (void)context_->IntersectDomainWithAndUpdateHint(
11772+
var, Domain(implied.Min()));
1176711773
} else if (cost > 0) {
1176811774
value1 = context_->MinOf(var);
1176911775
value2 = implied.Min();
@@ -11775,7 +11781,7 @@ void CpModelPresolver::ProcessVariableOnlyUsedInEncoding(int var) {
1177511781
// Nothing else to do in this case, the constraint will be reduced to
1177611782
// a pure Boolean constraint later.
1177711783
context_->UpdateRuleStats("variables: reduced domain to two values");
11778-
return (void)context_->IntersectDomainWith(
11784+
return (void)context_->IntersectDomainWithAndUpdateHint(
1177911785
var, Domain::FromValues({value1, value2}));
1178011786
}
1178111787
}
@@ -12004,6 +12010,7 @@ void CpModelPresolver::ProcessVariableOnlyUsedInEncoding(int var) {
1200412010
}
1200512011
PresolveAtMostOne(new_ct);
1200612012
}
12013+
if (context_->ModelIsUnsat()) return;
1200712014

1200812015
// Add enough constraints to the mapping model to recover a valid value
1200912016
// for var when all the booleans are fixed.

ortools/sat/docs/scheduling.md

Lines changed: 261 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2609,4 +2609,265 @@ def transitions_in_no_overlap_sample_sat():
26092609
transitions_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

Comments
 (0)