Solving by phases
Source: scheduling/example_32_solving_by_phases.py
What it does
Demonstrates warm-starting CP-SAT across multiple solves of the same model.
create_model(...)returns a campaigning-with-machines model similar to 28.- A list of
phaseswith growingmax_timeis defined. - For each phase, the model is solved. The resulting solution is read
back with
get_solutions(model, solver)and fed as hints to the next phase using a customadd_hints(model, solution)helper. model.clear_hints()resets between phases.
This is useful when a large horizon is needed for feasibility but a short horizon gives a fast starting point.
Concepts
- Solver techniques (hints, phases)
- Campaigning
Source
from ortools.sat.python import cp_model
from time import time
from matplotlib import pyplot as plt
from matplotlib.ticker import MaxNLocator
from ortools.sat import cp_model_pb2
import pandas as pd
import numpy as np
import string
def generate_task_data(num_of_products, num_of_tasks_per_product):
""" Generate the same number of tasks for multiple products (no more than 26 products please) """
products = string.ascii_uppercase[0:num_of_products]
total_num_of_tasks = num_of_tasks_per_product*num_of_products
tasks = {x for x in range(total_num_of_tasks)}
task_to_product = {}
for product_idx, product in enumerate(products):
task_to_product.update({
product_idx*num_of_tasks_per_product+task_idx: product for task_idx in range(num_of_tasks_per_product)
})
return tasks, task_to_product
def create_model(number_of_products, num_of_tasks_per_product, campaign_size, number_of_machines, print_result=True):
model = cp_model.CpModel()
changeover_time = 2
max_time = num_of_tasks_per_product*number_of_products*5
processing_time = 1
machines = {x for x in range(number_of_machines)}
tasks, task_to_product = generate_task_data(number_of_products, num_of_tasks_per_product)
print('Input data:\nTasks:', tasks, task_to_product, '\n')
product_change_indicator = {
(t1, t2): 0 if task_to_product[t1] == task_to_product[t2] else 1 for t1 in tasks for t2 in tasks if t1 != t2
}
var_task_starts = {task: model.new_int_var(0, max_time, f"task_{task}_start") for task in tasks}
var_task_ends = {task: model.new_int_var(0, max_time, f"task_{task}_end") for task in tasks}
var_machine_task_starts = {(m, t): model.new_int_var(0, max_time, f"m{m}_t{t}_start") for t in tasks for m in machines}
var_machine_task_ends = {(m, t): model.new_int_var(0, max_time, f"m{m}_t{t}_end") for t in tasks for m in machines}
var_machine_task_presences = {(m, t): model.new_bool_var(f"pre_{m}_{t}") for t in tasks for m in machines}
var_machine_task_rank = {(m, t): model.new_int_var(0, campaign_size-1, f"t_{t}_cu") for t in tasks for m in machines}
# No influence on the final result. Not need to lock the starting rank values of the first tasks per product to be 0
# for product_idx, product in enumerate(range(number_of_products)):
# print(f"Lock the rank of task {product_idx*num_of_tasks_per_product} to zero on all machines")
# for m in machines:
# model.add(var_machine_task_rank[m, product_idx*num_of_tasks_per_product] == 0)
var_m_t_reach_campaign_end = {(m, t): model.new_bool_var(f"t{t}_reach_max_on_m{m}") for t in tasks for m in machines}
var_m_t_product_change = {(m, t): model.new_bool_var(f"task_{t}_change_product_on_m{m}") for t in tasks for m in machines}
# This is optional
# model.add_decision_strategy(
# var_m_t_product_change.values(),
# cp_model.CHOOSE_FIRST,
# cp_model.SELECT_MIN_VALUE
# )
# Heuristic: Lock the sequence of the tasks (assume the deadlines are in the task order
# AND a task with later deadline shall not start earlier than a task with a earlier deadline)
# print("\nApply the tasks sequence heuristics")
# # Option 1: Locking the sequence of tasks per product! This is slower (7.54s for 3, 4, 4)
# for product_idx, product in enumerate(range(number_of_products)):
# for task_id_in_product_group, task in enumerate(range(num_of_tasks_per_product)):
# _index = product_idx * num_of_tasks_per_product + task_id_in_product_group
# if task_id_in_product_group == 0:
# print(f"\nLock {_index}", end=" ")
# else:
# print(f" <= {_index}", end=" ")
# model.add(var_task_ends[_index-1] <= var_task_starts[_index])
# print("\n")
# These intervals is needed otherwise the duration is not constrained
var_machine_task_intervals = {
(m, t): model.new_optional_interval_var(
var_machine_task_starts[m, t],
processing_time,
var_machine_task_ends[m, t],
var_machine_task_presences[m, t],
f"task_{t}_interval_on_m_{m}")
for t in tasks for m in machines
}
# each task is only present in one machine
for task in tasks:
task_candidate_machines = machines
tmp = [
var_machine_task_presences[m, task]
for m in task_candidate_machines
]
model.add_exactly_one(tmp)
# link task-level to machine-task level for start time & end time
for task in tasks:
task_candidate_machines = machines
for m in task_candidate_machines:
model.add(
var_task_starts[task] == var_machine_task_starts[m, task]
).only_enforce_if(var_machine_task_presences[m, task])
model.add(
var_task_ends[task] == var_machine_task_ends[m, task]
).only_enforce_if(var_machine_task_presences[m, task])
# Set objective to minimize make-span
make_span = model.new_int_var(0, max_time, "make_span")
total_changeover_time = model.new_int_var(0, max_time, "total_changeover_time")
model.add_max_equality(make_span, [var_task_ends[task] for task in tasks])
model.add(total_changeover_time == sum(var_m_t_reach_campaign_end[m,t] for m in machines for t in tasks))
model.minimize(make_span)
# the bool variables to indicator if t1 -> t2
literals = {(m, t1, t2): model.new_bool_var(f"{t1} -> {t2}")
for m in machines for t1 in tasks for t2 in tasks if t1 != t2}
# the technical variables to allow flexible campaigning
max_values = {(m, t1, t2): model.new_int_var(0, max_time, f"{t1} -> {t2}")
for m in machines for t1 in tasks for t2 in tasks if t1 != t2}
# schedule the tasks
for m in machines:
arcs = []
for t1 in tasks:
arcs.append([-1, t1, model.new_bool_var(f"first_to_{t1}")])
arcs.append([t1, -1, model.new_bool_var(f"{t1}_to_last")])
arcs.append([t1, t1, ~var_machine_task_presences[(m, t1)]])
for t2 in tasks:
if t1 == t2:
continue
arcs.append([t1, t2, literals[m, t1, t2]])
# If A -> B then var_m_t_product_change>=1 (can be 0 if the last task in a machine)
model.add(var_m_t_product_change[m, t1] >= product_change_indicator[t1, t2]).only_enforce_if(
literals[m, t1, t2]
)
# If var_m_t_product_change=1 then the campaign must end
model.add(var_m_t_reach_campaign_end[m, t1] >= var_m_t_product_change[m, t1])
# if the campaign ends then there must be changeover time
# [ task1 ] -> [ C/O ] -> [ task 2]
model.add(
var_task_ends[t1] + var_m_t_reach_campaign_end[m, t1]*changeover_time <= var_task_starts[t2]
).only_enforce_if(
literals[m, t1, t2]
)
# model could also decide to end the campaign before it reaches size limit, then reset the rank for t2
# has to do this in two steps since add_max_equality is not compatible with only_enforce_if
model.add_max_equality(
max_values[m, t1, t2],
[0, var_machine_task_rank[m, t1] + 1 - var_m_t_reach_campaign_end[m, t1]*campaign_size]
)
model.add(var_machine_task_rank[m, t2] == max_values[m, t1, t2]).only_enforce_if(literals[m, t1, t2])
model.add_circuit(arcs)
return model
# This takes 16 seconds
model = create_model(5, 20, 3, 1)
# solver = cp_model.CpSolver()
# start = time()
# status = solver.solve(model=model)
# total_time = time() - start
# print(status)
# print(solver.objective_value())
# #10
# print(total_time, status)
phases = [
{'phase_id': 0, 'max_time': 0.5},
{'phase_id': 1, 'max_time': 1},
{'phase_id': 2, 'max_time': 2},
{'phase_id': 3, 'max_time': 4},
{'phase_id': 4, 'max_time': 8}
]
phases = [
{'phase_id': 0, 'max_time': 10},
{'phase_id': 1, 'max_time': 10},
{'phase_id': 2, 'max_time': 10},
{'phase_id': 3, 'max_time': 10},
{'phase_id': 4, 'max_time': 10}
]
phases = [
{'phase_id': 0, 'max_time': 5},
{'phase_id': 1, 'max_time': 10},
{'phase_id': 2, 'max_time': 15},
{'phase_id': 3, 'max_time': 20},
{'phase_id': 4, 'max_time': 25}
]
n = 6
phases = [
{'phase_id': i, 'max_time': (i+1)*10} for i in range(n)
]
def get_solutions(model, solver):
vars_sol = {}
for i, var in enumerate(model.proto().variables):
value = solver.response_proto().solution[i]
vars_sol[var.name] = value
return vars_sol
def add_hints(model, solution):
for i, var in enumerate(model.proto().variables):
if var.name in solution:
model.proto().solution_hint.vars.append(i)
model.proto().solution_hint.values.append(solution[var.name])
# get_solutions(model, solver)
obj_list = []
for phase in phases:
phase_id, max_time = phase['phase_id'], phase['max_time']
print('----------------------------')
model.clear_hints()
if phase_id == 0:
solver = cp_model.CpSolver()
if phase_id > 0 and 'solution' in locals():
print("Add hints")
add_hints(model, solution)
print('number of variables in solution hints:', len(model.proto().solution_hint.vars))
#solver.parameters.keep_all_feasible_solutions_in_presolve = True
solver.parameters.max_time_in_seconds = max_time
start = time()
status = solver.solve(model=model)
print('number of solutions:', solver.response_proto().solution)
if status == 1 or status == 3:
print(f'error status : {status}')
break
if status == 0:
print(f'Cannot find a feasible solution in the given time {max_time}. status:{status}')
obj_list.append(np.nan)
continue
obj_value = solver.objective_value()
obj_list.append(obj_value)
solution = get_solutions(model, solver)
total_time = time() - start
print(f"phase_id: {phase_id}, max_time: {max_time}, status: {status}, obj: {obj_value}. total time: {round(total_time,1)}")
if status == 4:
print('======================================================')
print('Optimal Solution Achieved ! No need to continue')
break
print(obj_list)
times = [(i+1)*5 for i in range(n)]
ax = plt.figure().gca()
ax.xaxis.set_major_locator(MaxNLocator(integer=True))
plt.plot(times, obj_list, marker='o')
plt.legend()
plt.title(f'time vs obj')
plt.xlabel('running seconds')
plt.ylabel('obj')
plt.show()
# self.alg_data, self.model = solver.solve(
# alg_data=self.alg_data,
# previous_model=self.model,
# model_parameters=self.model_parameters,
# )
#
#
# vars_sol = {}
# for i, var in enumerate(model.proto().variables):
# value = solver.response_proto().solution[i]
# vars_sol[var.name] = value
#
#
#
# solver = cp_model.CpSolver()
# solver.parameters.max_time_in_seconds = 0.5
# start = time()
# status = solver.solve(model=model)
# total_time = time() - start
# print(total_time, status)
#
# #model.ExportToFile("C:/Temp/xxxx.pb.txt")
#
# start = time()
# status = solver.solve(model=model)
# total_time = time() - start
# print(total_time, status)
#
# start = time()
# status = solver.solve(model=model)
# total_time = time() - start
# print(total_time, status)
#
# start = time()
# status = solver.solve(model=model)
# total_time = time() - start
# print(total_time, status)
#
# vars_sol = {}
# for i, var in enumerate(model.proto().variables):
# print(var)
# print(var.name)
# print(var.domain)
# print('-------------------')
# vars_sol[var.name] = var
#
# model.proto().solution_hint
# for i, var in enumerate(model.proto().variables):
# if var.name in vars_sol:
# model.proto().solution_hint.vars.append(i)
# model.proto().solution_hint.values.append(vars_sol[var.name])
# #
#
# print(model.ModelStats())
# @dataclass
# class PhaseParameters:
# """ Gather different input optimisation parameters that can be set for each phase. """
#
# obj_phase: str = None
# max_time_in_seconds: int = None
# solution_count_limit: int = None
# restart: bool = False
#
#
# phase_1 = PhaseParameters('phase_1', 10)
# phase_2 = PhaseParameters('phase_2', 10)
#
#
# show the result if getting the optimal one