Files
ortools-clone/examples/python/bus_driver_scheduling_sat.py
2019-05-29 09:18:30 +02:00

392 lines
16 KiB
Python

# Copyright 2010-2018 Google LLC
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""This model implements a bus driver scheduling problem.
Constraints:
- max driving time per driver <= 9h
- max working time per driver <= 12h
- min working time per driver >= 6.5h (soft)
- 30 min break after each 4h of driving time per driver
- 10 min preparation time before the first shift
- 15 min cleaning time after the last shift
- 2 min waiting time after each shift for passenger boarding and alighting
"""
from __future__ import print_function
import collections
import math
from ortools.sat.python import cp_model
SAMPLE_SHIFTS = [
#
# column description:
# - shift id
# - shift start time as hh:mm string (for logging and readability purposes)
# - shift end time as hh:mm string (for logging and readability purposes)
# - shift start minute
# - shift end minute
# - shift duration in minutes
#
[0, '05:18', '06:00', 318, 360, 42],
[1, '05:26', '06:08', 326, 368, 42],
[2, '05:40', '05:56', 340, 356, 16],
[3, '06:06', '06:51', 366, 411, 45],
[4, '06:40', '07:52', 400, 472, 72],
[5, '06:42', '07:13', 402, 433, 31],
[6, '06:48', '08:15', 408, 495, 87],
[7, '06:59', '08:07', 419, 487, 68],
[8, '07:20', '07:36', 440, 456, 16],
[9, '07:35', '08:22', 455, 502, 47],
[10, '07:50', '08:55', 470, 535, 65],
[11, '08:00', '09:05', 480, 545, 65],
[12, '08:00', '08:35', 480, 515, 35],
[13, '08:11', '09:41', 491, 581, 90],
[14, '08:28', '08:50', 508, 530, 22],
[15, '08:35', '08:45', 515, 525, 10],
[16, '08:40', '08:50', 520, 530, 10],
[17, '09:03', '10:28', 543, 628, 85],
[18, '09:23', '09:49', 563, 589, 26],
[19, '09:30', '09:40', 570, 580, 10],
[20, '09:57', '10:20', 597, 620, 23],
[21, '10:09', '11:03', 609, 663, 54],
[22, '10:20', '10:30', 620, 630, 10],
[23, '11:00', '11:10', 660, 670, 10],
[24, '11:45', '12:24', 705, 744, 39],
[25, '12:18', '13:00', 738, 780, 42],
[26, '13:18', '14:44', 798, 884, 86],
[27, '13:53', '14:49', 833, 889, 56],
[28, '14:03', '14:50', 843, 890, 47],
[29, '14:28', '15:15', 868, 915, 47],
[30, '14:30', '15:41', 870, 941, 71],
[31, '14:48', '15:35', 888, 935, 47],
[32, '15:03', '15:50', 903, 950, 47],
[33, '15:28', '16:54', 928, 1014, 86],
[34, '15:38', '16:25', 938, 985, 47],
[35, '15:40', '15:56', 940, 956, 16],
[36, '15:58', '16:45', 958, 1005, 47],
[37, '16:04', '17:30', 964, 1050, 86],
[38, '16:28', '17:15', 988, 1035, 47],
[39, '16:36', '17:21', 996, 1041, 45],
[40, '16:50', '17:00', 1010, 1020, 10],
[41, '16:54', '18:20', 1014, 1100, 86],
[42, '17:01', '17:13', 1021, 1033, 12],
[43, '17:19', '18:31', 1039, 1111, 72],
[44, '17:23', '18:10', 1043, 1090, 47],
[45, '17:34', '18:15', 1054, 1095, 41],
[46, '18:04', '19:29', 1084, 1169, 85],
[47, '18:34', '19:58', 1114, 1198, 84],
[48, '19:56', '20:34', 1196, 1234, 38],
[49, '20:05', '20:48', 1205, 1248, 43]
]
def bus_driver_scheduling(minimize_drivers, max_num_drivers):
"""Optimize the bus driver scheduling problem.
This model has two modes.
If minimize_drivers == True, the objective will be to find the minimal
number of drivers, independently of the working times of each drivers.
Otherwise, will will create max_num_drivers non optional drivers, and
minimize the sum of working times of these drivers.
"""
num_shifts = len(SAMPLE_SHIFTS)
# All durations are in minutes.
max_driving_time = 540 # 8 hours.
max_driving_time_without_pauses = 240 # 4 hours
min_pause_after_4h = 30
min_delay_between_shifts = 2
max_working_time = 720
min_working_time = 390 # 6.5 hours
setup_time = 10
cleanup_time = 15
# Computed data.
total_driving_time = sum(shift[5] for shift in SAMPLE_SHIFTS)
min_num_drivers = int(
math.ceil(total_driving_time * 1.0 / max_driving_time))
num_drivers = 2 * min_num_drivers if minimize_drivers else max_num_drivers
min_start_time = min(shift[3] for shift in SAMPLE_SHIFTS)
max_end_time = max(shift[4] for shift in SAMPLE_SHIFTS)
print('Bus driver scheduling')
print(' num shifts =', num_shifts)
print(' total driving time =', total_driving_time, 'minutes')
print(' min num drivers =', min_num_drivers)
print(' num drivers =', num_drivers)
print(' min start time =', min_start_time)
print(' max end time =', max_end_time)
model = cp_model.CpModel()
# For each driver and each shift, we store:
# - the total driving time including this shift
# - the acrued driving time since the last 30 minute break
# Special arcs have the following effect:
# - 'from source to shift' sets the starting time and accumulate the first
# shift
# - 'from shift to end' sets the ending time, and fill the driving_times
# variable
# Arcs between two shifts have the following impact
# - add the duration of the shift to the total driving time
# - reset the accumulated driving time if the distance between the two
# shifts is more than 30 minutes, add the duration of the shift to the
# accumulated driving time since the last break otherwise
# Per (driver, node) info (driving time, performed,
# driving time since break)
total_driving = {}
no_break_driving = {}
performed = {}
starting_shifts = {}
# Per driver info (start, end, driving times, is working)
start_times = []
end_times = []
driving_times = []
working_drivers = []
working_times = []
# Weighted objective
delay_literals = []
delay_weights = []
# Used to propagate more between drivers
shared_incoming_literals = collections.defaultdict(list)
shared_outgoing_literals = collections.defaultdict(list)
for d in range(num_drivers):
start_times.append(
model.NewIntVar(min_start_time - setup_time, max_end_time,
'start_%i' % d))
end_times.append(
model.NewIntVar(min_start_time, max_end_time + cleanup_time,
'end_%i' % d))
driving_times.append(
model.NewIntVar(0, max_driving_time, 'driving_%i' % d))
working_times.append(
model.NewIntVar(0, max_working_time, 'working_times_%i' % d))
incoming_literals = collections.defaultdict(list)
outgoing_literals = collections.defaultdict(list)
outgoing_source_literals = []
incoming_sink_literals = []
# Create all the shift variables before iterating on the transitions
# between these shifts.
for s in range(num_shifts):
total_driving[d, s] = model.NewIntVar(0, max_driving_time,
'dr_%i_%i' % (d, s))
no_break_driving[d, s] = model.NewIntVar(
0, max_driving_time_without_pauses, 'mdr_%i_%i' % (d, s))
performed[d, s] = model.NewBoolVar('performed_%i_%i' % (d, s))
for s in range(num_shifts):
shift = SAMPLE_SHIFTS[s]
duration = shift[5]
# Arc from source to shift.
# - set the start time of the driver
# - increase driving time and driving time since break
source_lit = model.NewBoolVar('%i from source to %i' % (d, s))
outgoing_source_literals.append(source_lit)
incoming_literals[s].append(source_lit)
shared_incoming_literals[s].append(source_lit)
model.Add(start_times[d] == shift[3] -
setup_time).OnlyEnforceIf(source_lit)
model.Add(
total_driving[d, s] == duration).OnlyEnforceIf(source_lit)
model.Add(
no_break_driving[d, s] == duration).OnlyEnforceIf(source_lit)
starting_shifts[d, s] = source_lit
# Arc from shift to sink
# - set the end time of the driver
# - set the driving times of the driver
sink_lit = model.NewBoolVar('%i from %i to sink' % (d, s))
outgoing_literals[s].append(sink_lit)
shared_outgoing_literals[s].append(sink_lit)
incoming_sink_literals.append(sink_lit)
model.Add(end_times[d] == shift[4] +
cleanup_time).OnlyEnforceIf(sink_lit)
model.Add(driving_times[d] == total_driving[d, s]).OnlyEnforceIf(
sink_lit)
# Node not performed
# - set both driving times to 0
# - add a looping arc on the node
model.Add(total_driving[d, s] == 0).OnlyEnforceIf(
performed[d, s].Not())
model.Add(no_break_driving[d, s] == 0).OnlyEnforceIf(
performed[d, s].Not())
incoming_literals[s].append(performed[d, s].Not())
outgoing_literals[s].append(performed[d, s].Not())
# Not adding to the shared lists, because, globally, each node will have
# one incoming literal, and one outgoing literal.
# Node performed:
# - add upper bound on start_time
# - add lower bound on end_times
model.Add(start_times[d] <= shift[3] - setup_time).OnlyEnforceIf(
performed[d, s])
model.Add(end_times[d] >= shift[4] + cleanup_time).OnlyEnforceIf(
performed[d, s])
for o in range(num_shifts):
other = SAMPLE_SHIFTS[o]
delay = other[3] - shift[4]
if delay < min_delay_between_shifts:
continue
lit = model.NewBoolVar('%i from %i to %i' % (d, s, o))
# Increase driving time
model.Add(total_driving[d, o] == total_driving[d, s] +
other[5]).OnlyEnforceIf(lit)
# Increase no_break_driving or reset it to 0 depending on the delay
if delay >= min_pause_after_4h:
model.Add(
no_break_driving[d, o] == other[5]).OnlyEnforceIf(lit)
else:
model.Add(
no_break_driving[d, o] == no_break_driving[d, s] +
other[5]).OnlyEnforceIf(lit)
# Add arc
outgoing_literals[s].append(lit)
shared_outgoing_literals[s].append(lit)
incoming_literals[o].append(lit)
shared_incoming_literals[o].append(lit)
# Cost part
delay_literals.append(lit)
delay_weights.append(delay)
model.Add(working_times[d] == end_times[d] - start_times[d])
if minimize_drivers:
# Driver is not working.
working = model.NewBoolVar('working_%i' % d)
model.Add(start_times[d] == min_start_time).OnlyEnforceIf(
working.Not())
model.Add(end_times[d] == min_start_time).OnlyEnforceIf(
working.Not())
model.Add(driving_times[d] == 0).OnlyEnforceIf(working.Not())
working_drivers.append(working)
outgoing_source_literals.append(working.Not())
incoming_sink_literals.append(working.Not())
# Conditional working time constraints
model.Add(
working_times[d] >= min_working_time).OnlyEnforceIf(working)
model.Add(working_times[d] == 0).OnlyEnforceIf(working.Not())
else:
# Working time constraints
model.Add(working_times[d] >= min_working_time)
# Create circuit constraint.
model.Add(sum(outgoing_source_literals) == 1)
for s in range(num_shifts):
model.Add(sum(outgoing_literals[s]) == 1)
model.Add(sum(incoming_literals[s]) == 1)
model.Add(sum(incoming_sink_literals) == 1)
# Each shift is covered.
for s in range(num_shifts):
model.Add(sum(performed[d, s] for d in range(num_drivers)) == 1)
# Globally, each node has one incoming and one outgoing literal
model.Add(sum(shared_incoming_literals[s]) == 1)
model.Add(sum(shared_outgoing_literals[s]) == 1)
# Symmetry breaking
# The first 3 shifts must be performed by 3 different drivers.
# Let's assign them to the first 3 drivers in sequence
model.Add(starting_shifts[0, 0] == 1)
model.Add(starting_shifts[1, 1] == 1)
model.Add(starting_shifts[2, 2] == 1)
if minimize_drivers:
# Push non working drivers to the end
for d in range(num_drivers - 1):
model.AddImplication(working_drivers[d].Not(),
working_drivers[d + 1].Not())
# Redundant constraints: sum of driving times = sum of shift driving times
model.Add(sum(driving_times) == total_driving_time)
if not minimize_drivers:
model.Add(
sum(working_times) == total_driving_time +
num_drivers * (setup_time + cleanup_time) +
cp_model.LinearExpr.ScalProd(delay_literals, delay_weights))
if minimize_drivers:
# Minimize the number of working drivers
model.Minimize(sum(working_drivers))
else:
# Minimize the sum of delays between tasks, which in turns minimize the
# sum of working times as the total driving time is fixed
model.Minimize(
cp_model.LinearExpr.ScalProd(delay_literals, delay_weights))
# Solve model.
solver = cp_model.CpSolver()
solver.parameters.log_search_progress = True #not minimize_drivers
solver.parameters.num_search_workers = 8
status = solver.Solve(model)
if status != cp_model.OPTIMAL and status != cp_model.FEASIBLE:
return -1
# Display solution
if minimize_drivers:
max_num_drivers = int(solver.ObjectiveValue())
print('minimal number of drivers =', max_num_drivers)
return max_num_drivers
for d in range(num_drivers):
print('Driver %i: ' % (d + 1))
print(' total driving time =', solver.Value(driving_times[d]))
print(' working time =',
solver.Value(working_times[d]) + setup_time + cleanup_time)
first = True
for s in range(num_shifts):
shift = SAMPLE_SHIFTS[s]
if not solver.BooleanValue(performed[d, s]):
continue
# Hack to detect if the waiting time between the last shift and
# this one exceeds 30 minutes. For this, we look at the
# no_break_driving which was reinitialized in that case.
if solver.Value(no_break_driving[d, s]) == shift[5] and not first:
print(' **break**')
print(' shift ', shift[0], ':', shift[1], "-", shift[2])
first = False
return int(solver.ObjectiveValue())
def optimize_bus_driver_allocation():
"""Optimize the bus driver allocation in two passes."""
print('----------- first pass: minimize the number of drivers')
num_drivers = bus_driver_scheduling(True, -1)
print('----------- second pass: minimize the sum of working times')
bus_driver_scheduling(False, num_drivers)
optimize_bus_driver_allocation()