├── Pipfile ├── Pipfile.lock ├── README.md ├── config.py ├── helper.py ├── main.py └── sample.json /Pipfile: -------------------------------------------------------------------------------- 1 | [[source]] 2 | name = "pypi" 3 | url = "https://pypi.org/simple" 4 | verify_ssl = true 5 | 6 | [dev-packages] 7 | 8 | [packages] 9 | wheel = "*" 10 | six = "*" 11 | ortools = "*" 12 | pandas = "*" 13 | 14 | [requires] 15 | python_version = "3.7" -------------------------------------------------------------------------------- /Pipfile.lock: -------------------------------------------------------------------------------- 1 | { 2 | "_meta": { 3 | "hash": { 4 | "sha256": "bbc452a4f03eddaddb77d7db890ab62cafec597d2de1e77ae00c4a8af4d66734" 5 | }, 6 | "pipfile-spec": 6, 7 | "requires": { 8 | "python_version": "3.7" 9 | }, 10 | "sources": [ 11 | { 12 | "name": "pypi", 13 | "url": "https://pypi.org/simple", 14 | "verify_ssl": true 15 | } 16 | ] 17 | }, 18 | "default": { 19 | "numpy": { 20 | "hashes": [ 21 | "sha256:082f8d4dd69b6b688f64f509b91d482362124986d98dc7dc5f5e9f9b9c3bb983", 22 | "sha256:1bc0145999e8cb8aed9d4e65dd8b139adf1919e521177f198529687dbf613065", 23 | "sha256:309cbcfaa103fc9a33ec16d2d62569d541b79f828c382556ff072442226d1968", 24 | "sha256:3673c8b2b29077f1b7b3a848794f8e11f401ba0b71c49fbd26fb40b71788b132", 25 | "sha256:480fdd4dbda4dd6b638d3863da3be82873bba6d32d1fc12ea1b8486ac7b8d129", 26 | "sha256:56ef7f56470c24bb67fb43dae442e946a6ce172f97c69f8d067ff8550cf782ff", 27 | "sha256:5a936fd51049541d86ccdeef2833cc89a18e4d3808fe58a8abeb802665c5af93", 28 | "sha256:5b6885c12784a27e957294b60f97e8b5b4174c7504665333c5e94fbf41ae5d6a", 29 | "sha256:667c07063940e934287993366ad5f56766bc009017b4a0fe91dbd07960d0aba7", 30 | "sha256:7ed448ff4eaffeb01094959b19cbaf998ecdee9ef9932381420d514e446601cd", 31 | "sha256:8343bf67c72e09cfabfab55ad4a43ce3f6bf6e6ced7acf70f45ded9ebb425055", 32 | "sha256:92feb989b47f83ebef246adabc7ff3b9a59ac30601c3f6819f8913458610bdcc", 33 | "sha256:935c27ae2760c21cd7354402546f6be21d3d0c806fffe967f745d5f2de5005a7", 34 | "sha256:aaf42a04b472d12515debc621c31cf16c215e332242e7a9f56403d814c744624", 35 | "sha256:b12e639378c741add21fbffd16ba5ad25c0a1a17cf2b6fe4288feeb65144f35b", 36 | "sha256:b1cca51512299841bf69add3b75361779962f9cee7d9ee3bb446d5982e925b69", 37 | "sha256:b8456987b637232602ceb4d663cb34106f7eb780e247d51a260b84760fd8f491", 38 | "sha256:b9792b0ac0130b277536ab8944e7b754c69560dac0415dd4b2dbd16b902c8954", 39 | "sha256:c9591886fc9cbe5532d5df85cb8e0cc3b44ba8ce4367bd4cf1b93dc19713da72", 40 | "sha256:cf1347450c0b7644ea142712619533553f02ef23f92f781312f6a3553d031fc7", 41 | "sha256:de8b4a9b56255797cbddb93281ed92acbc510fb7b15df3f01bd28f46ebc4edae", 42 | "sha256:e1b1dc0372f530f26a03578ac75d5e51b3868b9b76cd2facba4c9ee0eb252ab1", 43 | "sha256:e45f8e981a0ab47103181773cc0a54e650b2aef8c7b6cd07405d0fa8d869444a", 44 | "sha256:e4f6d3c53911a9d103d8ec9518190e52a8b945bab021745af4939cfc7c0d4a9e", 45 | "sha256:ed8a311493cf5480a2ebc597d1e177231984c818a86875126cfd004241a73c3e", 46 | "sha256:ef71a1d4fd4858596ae80ad1ec76404ad29701f8ca7cdcebc50300178db14dfc" 47 | ], 48 | "version": "==1.19.1" 49 | }, 50 | "ortools": { 51 | "hashes": [ 52 | "sha256:12bcf3687e47f075ea552aa90f69b9951cab40a4ea24315ecaee3a72b498a6d6", 53 | "sha256:24d2358e2fc16bdeefe7b475adbc2545e662340f731189bd976a0c569d690207", 54 | "sha256:4fafeeeb4362bbbe6959915c7566ff59bbc2dbf976c701cabf027a526d6e1e86", 55 | "sha256:5462ab771d33b85ecce788bf050519e93f90010e2994c810e9533398dbfde263", 56 | "sha256:80f9bb9f5048de5654768b28bdd818827b968d943515dffe8f1bef23c0c243f2", 57 | "sha256:a60bee718b06c5832a7900e4a914fc69766d79554ea5e6e58972a7386a70e42f", 58 | "sha256:b2d0bdf6f8d6292ce6785a28acfaff0be56767599b3273baf35f759b8a161290", 59 | "sha256:bd246a46d5c37c88cedb6125ca9d0d161923581a1c014be6bc3dfaaf2a208d08", 60 | "sha256:d1c69bf43d8230497527bc89332696aa466900d88fa1dbc932b50571a2b659e6", 61 | "sha256:de23a68ade71857a97e8e4d26345ed81474ea126837a7430187f3e0d67982390", 62 | "sha256:e3a4393412b11a236091ddbfd1871c61aca219b0f725e80a338fc816fc2cbce2" 63 | ], 64 | "index": "pypi", 65 | "version": "==7.8.7959" 66 | }, 67 | "pandas": { 68 | "hashes": [ 69 | "sha256:0210f8fe19c2667a3817adb6de2c4fd92b1b78e1975ca60c0efa908e0985cbdb", 70 | "sha256:0227e3a6e3a22c0e283a5041f1e3064d78fbde811217668bb966ed05386d8a7e", 71 | "sha256:0bc440493cf9dc5b36d5d46bbd5508f6547ba68b02a28234cd8e81fdce42744d", 72 | "sha256:16504f915f1ae424052f1e9b7cd2d01786f098fbb00fa4e0f69d42b22952d798", 73 | "sha256:182a5aeae319df391c3df4740bb17d5300dcd78034b17732c12e62e6dd79e4a4", 74 | "sha256:35db623487f00d9392d8af44a24516d6cb9f274afaf73cfcfe180b9c54e007d2", 75 | "sha256:40ec0a7f611a3d00d3c666c4cceb9aa3f5bf9fbd81392948a93663064f527203", 76 | "sha256:47a03bfef80d6812c91ed6fae43f04f2fa80a4e1b82b35aa4d9002e39529e0b8", 77 | "sha256:4b21d46728f8a6be537716035b445e7ef3a75dbd30bd31aa1b251323219d853e", 78 | "sha256:4d1a806252001c5db7caecbe1a26e49a6c23421d85a700960f6ba093112f54a1", 79 | "sha256:60e20a4ab4d4fec253557d0fc9a4e4095c37b664f78c72af24860c8adcd07088", 80 | "sha256:9f61cca5262840ff46ef857d4f5f65679b82188709d0e5e086a9123791f721c8", 81 | "sha256:a15835c8409d5edc50b4af93be3377b5dd3eb53517e7f785060df1f06f6da0e2", 82 | "sha256:b39508562ad0bb3f384b0db24da7d68a2608b9ddc85b1d931ccaaa92d5e45273", 83 | "sha256:ed60848caadeacecefd0b1de81b91beff23960032cded0ac1449242b506a3b3f", 84 | "sha256:fc714895b6de6803ac9f661abb316853d0cd657f5d23985222255ad76ccedc25" 85 | ], 86 | "index": "pypi", 87 | "version": "==1.1.0" 88 | }, 89 | "protobuf": { 90 | "hashes": [ 91 | "sha256:0bba42f439bf45c0f600c3c5993666fcb88e8441d011fad80a11df6f324eef33", 92 | "sha256:1e834076dfef9e585815757a2c7e4560c7ccc5962b9d09f831214c693a91b463", 93 | "sha256:339c3a003e3c797bc84499fa32e0aac83c768e67b3de4a5d7a5a9aa3b0da634c", 94 | "sha256:361acd76f0ad38c6e38f14d08775514fbd241316cce08deb2ce914c7dfa1184a", 95 | "sha256:3dee442884a18c16d023e52e32dd34a8930a889e511af493f6dc7d4d9bf12e4f", 96 | "sha256:4d1174c9ed303070ad59553f435846a2f877598f59f9afc1b89757bdf846f2a7", 97 | "sha256:5db9d3e12b6ede5e601b8d8684a7f9d90581882925c96acf8495957b4f1b204b", 98 | "sha256:6a82e0c8bb2bf58f606040cc5814e07715b2094caeba281e2e7d0b0e2e397db5", 99 | "sha256:8c35bcbed1c0d29b127c886790e9d37e845ffc2725cc1db4bd06d70f4e8359f4", 100 | "sha256:91c2d897da84c62816e2f473ece60ebfeab024a16c1751aaf31100127ccd93ec", 101 | "sha256:9c2e63c1743cba12737169c447374fab3dfeb18111a460a8c1a000e35836b18c", 102 | "sha256:9edfdc679a3669988ec55a989ff62449f670dfa7018df6ad7f04e8dbacb10630", 103 | "sha256:c0c5ab9c4b1eac0a9b838f1e46038c3175a95b0f2d944385884af72876bd6bc7", 104 | "sha256:c8abd7605185836f6f11f97b21200f8a864f9cb078a193fe3c9e235711d3ff1e", 105 | "sha256:d69697acac76d9f250ab745b46c725edf3e98ac24763990b24d58c16c642947a", 106 | "sha256:df3932e1834a64b46ebc262e951cd82c3cf0fa936a154f0a42231140d8237060", 107 | "sha256:e7662437ca1e0c51b93cadb988f9b353fa6b8013c0385d63a70c8a77d84da5f9", 108 | "sha256:f68eb9d03c7d84bd01c790948320b768de8559761897763731294e3bc316decb" 109 | ], 110 | "version": "==3.13.0" 111 | }, 112 | "python-dateutil": { 113 | "hashes": [ 114 | "sha256:73ebfe9dbf22e832286dafa60473e4cd239f8592f699aa5adaf10050e6e1823c", 115 | "sha256:75bb3f31ea686f1197762692a9ee6a7550b59fc6ca3a1f4b5d7e32fb98e2da2a" 116 | ], 117 | "version": "==2.8.1" 118 | }, 119 | "pytz": { 120 | "hashes": [ 121 | "sha256:a494d53b6d39c3c6e44c3bec237336e14305e4f29bbf800b599253057fbb79ed", 122 | "sha256:c35965d010ce31b23eeb663ed3cc8c906275d6be1a34393a1d73a41febf4a048" 123 | ], 124 | "version": "==2020.1" 125 | }, 126 | "six": { 127 | "hashes": [ 128 | "sha256:30639c035cdb23534cd4aa2dd52c3bf48f06e5f4a941509c8bafd8ce11080259", 129 | "sha256:8b74bedcbbbaca38ff6d7491d76f2b06b3592611af620f8426e82dddb04a5ced" 130 | ], 131 | "index": "pypi", 132 | "version": "==1.15.0" 133 | }, 134 | "wheel": { 135 | "hashes": [ 136 | "sha256:497add53525d16c173c2c1c733b8f655510e909ea78cc0e29d374243544b77a2", 137 | "sha256:99a22d87add3f634ff917310a3d87e499f19e663413a52eb9232c447aa646c9f" 138 | ], 139 | "index": "pypi", 140 | "version": "==0.35.1" 141 | } 142 | }, 143 | "develop": {} 144 | } 145 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Employee Scheduling Problems 2 | 3 | Using Google Operation Research Tools to solve scheduling problems. 4 | 5 | Please refer to this article for more details about the problems (coming soon). 6 | 7 | ## General 8 | 9 | ### The problems 10 | 11 | The comany have a list of employees and list of jobs need to be done every week. 12 | Each job required specific skills and need to be done in specific locations. 13 | Each employee having their own skills and their home can be near or very far from job's location. Also, each of them having their own calendar, available on different time slots during the week. 14 | Find the solution to satified all of the constraint. 15 | 16 | The solution also need to optimize many objects for example: to help minimize employees travel time, working time and the work also to be done. 17 | 18 | ### Constraints 19 | 20 | - Each job execute by only 1 employee. 21 | - Each job need to done in specific location. 22 | - Employee cannot be working in a time slot which has been blocked on their calendar. 23 | - Jobs executing by employee cannot be overlap. 24 | - Job must be executed before requested date. 25 | - Time slot of employee happen from 6:00AM to 18:00AM, they will not work outside that hours. 26 | 27 | ### Objectives 28 | 29 | - Minimize employee's travelling time: As said above, some employee live very far from job location. Help them to travel least. 30 | - Location change: minimize location change between the jobs, during the week. 31 | - Date Change: Some employee want to finish the work earliest this week. For example, they don't want to work in Friday. Help them finish the work in days before. 32 | - Load balacing: the job need to be balance between employees. Can't be one person finish all the jobs, the others are free. 33 | - Job also have expected date and requested date to finish, it's better finish on expected date, rather than expected date. 34 | 35 | ## Modeling 36 | 37 | This part using lots of term from Google Ortools in specific, and optimization solver in general. Please refer to their documentation for any specific question. 38 | 39 | ### Decision Var 40 | 41 | Model Decision Var by IntVar including: 42 | 43 | - JOBS: `[Start, End, BoolVar]` (Starting time, Ending time, Duration, Optional Var: mean this job can be execute by this employee or not) 44 | - BLOCKED_TIMES: `[Start, End]` (This blocked times alway need to be execute by specific employee) 45 | - DUMMY: `[Start, End]` (Model dummy re-present as non-working hour jobs, this dummy to be filled during the week, together with 2 above) 46 | 47 | ### Specific Var 48 | 49 | - LOCATION: model this using CirCuit node. Each location will be a node, also employee's home is considering as a dummy node. 50 | 51 | ### Multi Objectives 52 | 53 | - Each objective has priority weight. Base on that, model the solution follow that priority. 54 | 55 | ## Build & Run 56 | 57 | Install Python 3.7 package with Pipenv and run python file: 58 | 59 | ```bash 60 | python3 main.py 61 | ``` 62 | 63 | ## Sample Results 64 | ``` 65 | Employee 0230509700 66 | start_79558_U2R8oIWr: Monday-13-7 67 | end_79558: Monday-16-10 68 | 69 | 70 | Employee 7222247981 71 | start_79558_mefZUjzH: Monday-13-7 72 | end_79558: Monday-16-10 73 | start_block_wonoXGV5: Monday-16-10 74 | end_block_wonoXGV5: Monday-19-13 75 | 76 | 77 | Employee 8875727446 78 | - None work 79 | 80 | Employee 6117206298 81 | start_79558_x7f4bzqP: Monday-13-7 82 | end_79558: Monday-16-10 83 | start_79558_SZntYmCs: Monday-19-13 84 | end_79558: Monday-22-16 85 | start_79558_pPjY3lwB: Monday-16-10 86 | end_79558: Monday-19-13 87 | ``` 88 | 89 | Each employee has their calendar, with a week day, and timeshift attached next to it. 90 | 91 | ## Modify Sample Data 92 | 93 | You can also add more jobs, add more locations and employees. 94 | Just make sure that the sample data will return FEARISLE solution. 95 | 96 | Data size increase means solver take longer time to get the solution. 97 | 98 | ## Perfomance 99 | 100 | Using AWS EC2 Instance with sample size 101 | 102 | - Coming soon 103 | 104 | ## Visualization 105 | 106 | Coming soon -------------------------------------------------------------------------------- /config.py: -------------------------------------------------------------------------------- 1 | ################### OPTIMIZATION FATOR ################### 2 | # PRIORITY FACTOR RANKED BY INTEGER FROM HIGHEST (SMALLEST INTEGER) TO LOWEST 3 | CONFIGS = { 4 | "location_change": 1, 5 | "travel_time": 2, 6 | "date_change": 3, 7 | "load_balancing": 4 8 | } 9 | 10 | # NUMBER OF WORKING DAYS PER WEEK CONVERT TO INTEGER 11 | days_per_week = 5 12 | weekdays_int = list(range(0, days_per_week)) 13 | 14 | # NUMBER OF HOUR PER DAY INCLUDING NON-WORKING HOUR 15 | HOURS_PER_DAY_MODEL = 24 16 | 17 | # NUMBER OF HOUR PER WEEK INCLUDING NON-WORKING HOUR 18 | horizon = days_per_week * HOURS_PER_DAY_MODEL 19 | 20 | 21 | ###################### SOLVER CONFIGURATION ################### 22 | # NUMBER OF PARALLEL WORKERS FOR RUNNING SOLVER. USUALLY THIS EQUAL TO NUMBER OF CPU 23 | search_workers = 12 24 | 25 | # OPTION PRINT LOG SEARCH DURING SOLVING PROBLEMS 26 | log_search_progress = True 27 | 28 | # LIMIT TIME TO SOLVE TO THE PROBLEMS IN SECONDS 29 | max_time_in_seconds = 24 * 60 * 60 30 | -------------------------------------------------------------------------------- /helper.py: -------------------------------------------------------------------------------- 1 | import math 2 | import datetime 3 | 4 | def integer_to_day_hour(num_integer, within_day=True, num_hours_per_day=24, start_hour_of_day=6): 5 | """Convert integer return from solver to readable date for logging or visualization.""" 6 | plus_number = num_integer % num_hours_per_day 7 | hours = start_hour_of_day + plus_number 8 | day = math.floor(num_integer / num_hours_per_day) 9 | if plus_number == 0 and day != 0 and not within_day: 10 | day = day - 1 11 | hours = start_hour_of_day + num_hours_per_day 12 | 13 | date = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday"][day] 14 | string_return = date + "-" + str(hours) 15 | return string_return 16 | 17 | def get_weekday_from_datetime(dt): 18 | year, month, day = (int(x) for x in dt.split('-')) 19 | weekday = datetime.date(year, month, day).weekday() 20 | return weekday 21 | 22 | def get_distance_between_point(distances_dict, measure_point, reference_point): 23 | """Returns the distance between tasks of job measure_point and tasks of job reference_point.""" 24 | key_tuple = (measure_point, reference_point) 25 | if key_tuple not in distances_dict.keys(): 26 | key_tuple = (reference_point, measure_point) 27 | hours = distances_dict[key_tuple] 28 | return hours 29 | 30 | def get_bound_of_weekday(hours_per_day_model, weekday, hour_from, hour_to): 31 | start_bound = weekday * hours_per_day_model + hour_from 32 | end_bound = start_bound + hour_to - hour_from 33 | return start_bound, end_bound 34 | -------------------------------------------------------------------------------- /main.py: -------------------------------------------------------------------------------- 1 | from ortools.sat.python import cp_model 2 | import collections 3 | import datetime 4 | import json 5 | import math 6 | 7 | from config import horizon, weekdays_int, HOURS_PER_DAY_MODEL, CONFIGS, search_workers, log_search_progress, max_time_in_seconds 8 | from helper import get_weekday_from_datetime, get_distance_between_point, get_bound_of_weekday, integer_to_day_hour 9 | 10 | with open('sample.json') as f: 11 | data = json.load(f) 12 | 13 | jobs = data['jobs'] 14 | blocked_times = data['blocked_times'] 15 | employees = data['employees'] 16 | locations = data['locations'] 17 | distances = data['distances'] 18 | 19 | ### LOGGING SIZE OF DATA TO MEASURE PERFORMANCE ### 20 | print("ASSIGNMENTS ", len(jobs)) 21 | print("BLOCKED_TIMES ", len(blocked_times)) 22 | print("EMPLOYEES ", len(employees)) 23 | print("HORIZON ", horizon) 24 | 25 | 26 | ### TRANSFORM DATE OF JOB & BLOCKING_TIMES TO INTEGER ### 27 | for j in jobs: 28 | j['origin_expected_date'] = j['expected_date'] 29 | j['expected_date'] = get_weekday_from_datetime(j['expected_date']) 30 | if not j['shipment_date']: 31 | j['shipment_date'] = -1 32 | else: 33 | j['shipment_date'] = get_weekday_from_datetime(j['shipment_date']) 34 | 35 | for b in blocked_times: 36 | b['origin_requested_date'] = b['requested_date'] 37 | b['requested_date'] = get_weekday_from_datetime(b['requested_date']) 38 | 39 | ### CONSTRUCT LOCATIONS OBJECTS ### 40 | locations_dict = {} 41 | for l in locations: 42 | locations_dict[l['location_id']] = l['employee_id'] 43 | 44 | print(locations_dict) 45 | 46 | ### CONSTRUCT DISTANCES OBJECTS ### 47 | total_distance_between_matrix = 0 48 | distances_dict = {} 49 | for distance_obj in distances: 50 | tuple_distance = (distance_obj["measure_point"], distance_obj["reference_point"]) 51 | distances_dict[tuple_distance] = distance_obj["hours"] 52 | total_distance_between_matrix += distance_obj["hours"] 53 | 54 | 55 | ### STARTING MODEL THE SOLUTION ### 56 | # INIT MODEL AND ASSIGN NAMING VARIABLES 57 | model = cp_model.CpModel() 58 | 59 | # Model JOB_TYPE with optional BOOL_VAR 60 | job_type = collections.namedtuple('booking_type', 'start end interval duration bool_var') 61 | block_type = collections.namedtuple('booking_type', 'start end interval duration') 62 | dummy_type = collections.namedtuple('dummy_type', 'start end interval duration') 63 | all_bookings = {} 64 | 65 | # Model JOBS 66 | for j in jobs: 67 | for e in employees: 68 | if j['job_type'] in e['skills'] or 'General' in e['skills']: 69 | label_tuple = (j['job_id'], e['employee_id']) 70 | start_var = model.NewIntVar(0, horizon, 'start_assign_%s_%s' % label_tuple) 71 | duration = j['job_duration'] 72 | end_var = model.NewIntVar(0, horizon, 'end_assign_%s_%s' % label_tuple) 73 | bool_var = model.NewBoolVar('bool_assign_%s_%s' % label_tuple) 74 | optional_interval_var = model.NewOptionalIntervalVar( 75 | start_var, duration, end_var, bool_var, 76 | 'optional_interval_assign_%s_%s' % label_tuple 77 | ) 78 | 79 | all_bookings[label_tuple] = job_type( 80 | start=start_var, end=end_var, interval=optional_interval_var, 81 | duration=duration, bool_var=bool_var 82 | ) 83 | 84 | # Model BLOCKING_TIMES 85 | for b in blocked_times: 86 | for e in employees: 87 | if b['employee_id'] == e['employee_id']: 88 | label_blocked = b['blocked_id'] 89 | start_var = model.NewIntVar(0, horizon, 'start_block_%s' % label_blocked) 90 | duration = b['job_duration'] 91 | end_var = model.NewIntVar(0, horizon, 'end_block_%s' % label_blocked) 92 | bool_var = model.NewBoolVar('bool_block_%s' % label_blocked) 93 | interval_var = model.NewIntervalVar( 94 | start_var, duration, end_var, 'interval_block_%s' % label_blocked 95 | ) 96 | 97 | all_bookings[label_blocked] = block_type( 98 | start=start_var, end=end_var, interval=interval_var, duration=duration 99 | ) 100 | 101 | # Model dummy blocks 102 | for w in weekdays_int: 103 | for e in employees: 104 | start_bound = w * HOURS_PER_DAY_MODEL 105 | 106 | # Dummy day 107 | label_dummy_day = (w, e['employee_id'], 'day') 108 | end_day_from = start_bound + 6 109 | start_day_var = model.NewIntVar( 110 | start_bound, end_day_from, 'start_day_dummy_%i_%s_%s' % label_dummy_day 111 | ) 112 | end_day_var = model.NewIntVar( 113 | start_bound, end_day_from, 'end_day_dummy_%i_%s_%s' % label_dummy_day 114 | ) 115 | duration_day = 6 116 | day_interval_var = model.NewIntervalVar( 117 | start_day_var, duration_day, end_day_var, 'interval_day_dummy_%i_%s_%s' % label_dummy_day 118 | ) 119 | all_bookings[label_dummy_day] = dummy_type( 120 | start=start_day_var, end=end_day_var, interval=day_interval_var, duration=duration_day 121 | ) 122 | 123 | # Dummy night 124 | label_dummy_night = (w, e['employee_id'], 'night') 125 | start_night_from = start_bound + 18 126 | end_night_from = (w + 1) * HOURS_PER_DAY_MODEL 127 | start_night_var = model.NewIntVar( 128 | start_night_from, end_night_from, 'start_night_dummy_%i_%s_%s' % label_dummy_night 129 | ) 130 | end_night_var = model.NewIntVar( 131 | start_night_from, end_night_from, 'end_night_dummy_%i_%s_%s' % label_dummy_night 132 | ) 133 | duration_night = HOURS_PER_DAY_MODEL - 18 134 | night_interval_var = model.NewIntervalVar( 135 | start_night_var, duration_night, end_night_var, 'interval_night_dummy_%i_%s_%s' % label_dummy_night 136 | ) 137 | all_bookings[label_dummy_night] = dummy_type( 138 | start=start_night_var, end=end_night_var, interval=night_interval_var, duration=duration_night 139 | ) 140 | 141 | 142 | ### CONSTRAINT AND OBJECTIVE FORMULATION ### 143 | # Each job execute by only 1 employee & load balancing | location mapping objective formulation 144 | diff_of_vector_balancing = [] 145 | avg_jobs_of_employees = int(len(jobs) / len(employees)) 146 | max_diff_balancing_integer = len(jobs) - avg_jobs_of_employees 147 | max_diff_balancing_var = model.NewIntVar(0, max_diff_balancing_integer, 'max_diff_balancing') 148 | bools_location_mapping = [] 149 | for j in jobs: 150 | bool_jobs = [] 151 | for e in employees: 152 | if j['job_type'] in e['skills'] or 'General' in e['skills']: 153 | label_tuple = (j['job_id'], e['employee_id']) 154 | bool_jobs.append(all_bookings[label_tuple].bool_var) 155 | if locations_dict[j['location_id']] == e['employee_id']: 156 | bools_location_mapping.append(all_bookings[label_tuple].bool_var.Not()) 157 | model.Add(sum(bool_jobs) == 1) 158 | 159 | # Model load balancing objective 160 | diff_var = model.NewIntVar(-avg_jobs_of_employees, max_diff_balancing_integer, 'diff_with_avg_%s' % j['job_id']) 161 | model.Add(diff_var == sum(bool_jobs) - avg_jobs_of_employees) 162 | abs_diff_of_balancing_var = model.NewIntVar(0, max_diff_balancing_integer, 'abs_diff_with_avg_%s' % j['job_id']) 163 | model.AddAbsEquality(abs_diff_of_balancing_var, diff_var) 164 | diff_of_vector_balancing.append(abs_diff_of_balancing_var) 165 | 166 | model.AddMaxEquality(max_diff_balancing_var, diff_of_vector_balancing) 167 | 168 | 169 | # Model date change 170 | abs_integer_dates_distance = [] 171 | # Travel time objective 172 | switch_transit_literals = [] 173 | switch_transition_times = [] 174 | # Distance objective 175 | total_avg_distances = 0 176 | for e in employees: 177 | intervals = [] 178 | executor_starts = [] 179 | executor_ends = [] 180 | executor_bools = [] 181 | executor_intervals = [] 182 | location_ids_mapping = [] 183 | 184 | distance_i_to_fs = 0 185 | # NoOverLap contraint for assignments 186 | for j in jobs: 187 | if j['job_type'] in e['skills'] or 'General' in e['skills']: 188 | label_tuple = (j['job_id'], e['employee_id']) 189 | booking = all_bookings[label_tuple] 190 | 191 | # Add to list for NoOverlap constraint 192 | intervals.append(booking.interval) 193 | # Add to executor node for dense graph 194 | executor_intervals.append(booking.interval) 195 | 196 | # Add to executor node for dense graph distance reference 197 | executor_starts.append(booking.start) 198 | executor_ends.append(booking.end) 199 | 200 | # Add to executor bool for dense graph node reference 201 | executor_bools.append(booking.bool_var) 202 | 203 | # Add to executor bool for dense graph location mapping 204 | location_ids_mapping.append(j['location_id']) 205 | 206 | # Booking must happens before shipment date (deadline) 207 | if j['shipment_date'] >= 0: 208 | deadline_start_bound, deadline_end_bound = get_bound_of_weekday( 209 | HOURS_PER_DAY_MODEL, j['shipment_date'], 6, 18 210 | ) 211 | model.Add(booking.end <= deadline_end_bound) 212 | 213 | # Model variable for date change objective 214 | integer_date_of_assignment_var = model.NewIntVar( 215 | 0, weekdays_int[-1], 'integer_date_assignment_%s_%s' % label_tuple 216 | ) 217 | integer_dates_distance_var = model.NewIntVar( 218 | -weekdays_int[-1], weekdays_int[-1], 'integer_date_distance_%s_%s' % label_tuple 219 | ) 220 | abs_distance_var = model.NewIntVar( 221 | 0, weekdays_int[-1], 'integer_date_distance_abs_%s_%s' % label_tuple 222 | ) 223 | model.AddDivisionEquality(integer_date_of_assignment_var, booking.start, HOURS_PER_DAY_MODEL) 224 | model.Add(integer_date_of_assignment_var - j['expected_date'] == integer_dates_distance_var) 225 | model.AddAbsEquality(abs_distance_var, integer_dates_distance_var) 226 | abs_integer_dates_distance.append(abs_distance_var) 227 | 228 | # Traveling time object avg 229 | distance = get_distance_between_point( 230 | distances_dict, e['employee_id'], j['location_id'] 231 | ) 232 | distance_i_to_fs += distance 233 | 234 | avg_distance_from_i_to_fs = int(distance_i_to_fs / len(employees)) 235 | total_avg_distances += avg_distance_from_i_to_fs 236 | 237 | for b in blocked_times: 238 | if b['employee_id'] == e['employee_id']: 239 | label_blocked = b['blocked_id'] 240 | booking = all_bookings[label_blocked] 241 | 242 | # Add to list for NoOverlap constraint 243 | intervals.append(booking.interval) 244 | 245 | # Booking must happens in requested_date date 246 | block_start_bound, block_end_bound = get_bound_of_weekday( 247 | HOURS_PER_DAY_MODEL, b['requested_date'], 6, 18 248 | ) 249 | model.Add(all_bookings[label_blocked].start >= block_start_bound) 250 | model.Add(all_bookings[label_blocked].end <= block_end_bound) 251 | 252 | dummy_bools = [] 253 | for w in weekdays_int: 254 | label_dummy_day = (w, e['employee_id'], 'day') 255 | booking_day = all_bookings[label_dummy_day] 256 | 257 | label_dummy_night = (w, e['employee_id'], 'night') 258 | booking_night = all_bookings[label_dummy_night] 259 | 260 | # Add to list for NoOverlap constraint 261 | intervals.append(booking_day.interval) 262 | intervals.append(booking_night.interval) 263 | # Add to executor node for dense graph 264 | executor_intervals.append(booking_day.interval) 265 | executor_intervals.append(booking_night.interval) 266 | 267 | bool_day = model.NewBoolVar('day_dummy_%i_%s_%s' % label_dummy_day) 268 | bool_night = model.NewBoolVar('night_dummy_%i_%s_%s' % label_dummy_night) 269 | # Add to bools list to indicate successor of dense graph 270 | dummy_bools.append(bool_day) 271 | dummy_bools.append(bool_night) 272 | # Add to executor bool for dense graph node reference 273 | executor_bools.append(bool_day) 274 | executor_bools.append(bool_night) 275 | # Add to executor bool for dense graph comparing distance 276 | executor_starts.append(booking_day.start) 277 | executor_starts.append(booking_night.start) 278 | executor_ends.append(booking_day.end) 279 | executor_ends.append(booking_night.end) 280 | 281 | location_ids_mapping.append(e['employee_id']) 282 | location_ids_mapping.append(e['employee_id']) 283 | 284 | # Enable to True all of dummy block 285 | model.Add(sum(dummy_bools) == len(weekdays_int * 2)) 286 | 287 | # Non overlap all tasks 288 | model.AddNoOverlap(intervals) 289 | 290 | # Model Distance and Objectives: travel time - location change 291 | arcs = [] 292 | for idx_i, a_i in enumerate(executor_intervals): 293 | # dummy node of CIRCUIT 294 | start_literal = model.NewBoolVar('%i_first_job' % idx_i) 295 | end_literal = model.NewBoolVar('%i_last_job' % idx_i) 296 | arcs.append([0, idx_i + 1, start_literal]) 297 | arcs.append([idx_i + 1, 0, end_literal]) 298 | # Self arc if the assignment is not performed. 299 | arcs.append([idx_i + 1, idx_i + 1, executor_bools[idx_i].Not()]) 300 | i_point = location_ids_mapping[idx_i] 301 | 302 | for idx_j, a_j in enumerate(executor_intervals): 303 | if idx_i == idx_j: 304 | continue 305 | 306 | literal = model.NewBoolVar('%i_follows_%i' % (idx_j, idx_i)) 307 | arcs.append([idx_i + 1, idx_j + 1, literal]) 308 | model.AddImplication(literal, executor_bools[idx_i]) 309 | model.AddImplication(literal, executor_bools[idx_j]) 310 | 311 | j_point = location_ids_mapping[idx_j] 312 | # Constraint distance if j is successor of i 313 | if i_point != j_point: 314 | # Constraint distance location <-> location 315 | i_to_j_distance = get_distance_between_point( 316 | distances_dict, i_point, j_point 317 | ) 318 | 319 | # Add to objective for location change and transition times 320 | switch_transit_literals.append(literal) 321 | switch_transition_times.append(i_to_j_distance) 322 | else: 323 | i_to_j_distance = 0 324 | 325 | # Reified transition to link the literals with the times 326 | model.Add( 327 | executor_starts[idx_j] >= executor_ends[idx_i] + i_to_j_distance 328 | ).OnlyEnforceIf(literal) 329 | 330 | model.AddCircuit(arcs) 331 | 332 | 333 | ### OBJECTIVES ### 334 | # Modeling normalize traveling time 335 | result_traveling_time_var = model.NewIntVar(0, total_distance_between_matrix, 'result_traveling_time') 336 | traveling_time_objective_var = model.NewIntVar(0, total_distance_between_matrix, 'traveling_time_objective') 337 | model.Add(sum([ 338 | s * switch_transition_times[idx] for idx, s in enumerate(switch_transit_literals) 339 | ]) == result_traveling_time_var) 340 | model.AddDivisionEquality( 341 | traveling_time_objective_var, result_traveling_time_var, total_avg_distances 342 | ) 343 | 344 | # MODEL MUTIPLE OBJECTS BY PRIORITY WEIGHT 345 | weights = [5 - val for key, val in CONFIGS.items()] 346 | objectives = [ 347 | sum(bools_location_mapping), 348 | traveling_time_objective_var, 349 | sum(abs_integer_dates_distance), 350 | max_diff_balancing_var 351 | ] 352 | 353 | model.Minimize(sum([w * objectives[idx] for idx, w in enumerate(weights)])) 354 | 355 | # Solve problem with model 356 | solver = cp_model.CpSolver() 357 | solver.parameters.num_search_workers = search_workers 358 | solver.parameters.log_search_progress = log_search_progress 359 | solver.parameters.max_time_in_seconds = 24 * 60 * 60 360 | status = solver.Solve(model) 361 | 362 | print(' - status : %s' % solver.StatusName(status)) 363 | print(' - conflicts : %i' % solver.NumConflicts()) 364 | print(' - branches : %i' % solver.NumBranches()) 365 | print(' - wall time : %f s' % solver.WallTime()) 366 | print(' - Objective : %f s' % solver.ObjectiveValue()) 367 | 368 | if status == cp_model.OPTIMAL or status == cp_model.FEASIBLE: 369 | for e in employees: 370 | print("Employee %s" % e['employee_id']) 371 | for j in jobs: 372 | if j['job_type'] in e['skills'] or 'General' in e['skills']: 373 | label_tuple = (j['job_id'], e['employee_id']) 374 | if solver.BooleanValue(all_bookings[label_tuple].bool_var): 375 | 376 | name_start = 'start_%s_%s' % (j['location_id'], j['job_id']) 377 | name_end = 'end_%s' %(j['location_id']) 378 | 379 | 380 | value_start = integer_to_day_hour(solver.Value(all_bookings[label_tuple].start), True) + "-" + str(solver.Value(all_bookings[label_tuple].start)) 381 | value_end = integer_to_day_hour(solver.Value(all_bookings[label_tuple].end), False) + "-" + str(solver.Value(all_bookings[label_tuple].end)) 382 | print(name_start + ": " + value_start) 383 | print(name_end + ": " + value_end) 384 | 385 | for b in blocked_times: 386 | if b['employee_id'] == e['employee_id']: 387 | label_blocked = b['blocked_id'] 388 | name_start = 'start_block_%s' % label_blocked 389 | name_end = 'end_block_%s' % label_blocked 390 | value_start = integer_to_day_hour(solver.Value(all_bookings[label_blocked].start), True) + "-" + str(solver.Value(all_bookings[label_blocked].start)) 391 | value_end = integer_to_day_hour(solver.Value(all_bookings[label_blocked].end), False) + "-" + str(solver.Value(all_bookings[label_blocked].end)) 392 | print(name_start + ": " + value_start) 393 | print(name_end + ": " + value_end) 394 | 395 | print("") 396 | print("") -------------------------------------------------------------------------------- /sample.json: -------------------------------------------------------------------------------- 1 | { 2 | "employees": [ 3 | { 4 | "employee_id": "0230509700", 5 | "name": "Ingrid Barrios", 6 | "skills": [ 7 | "A", 8 | "B", 9 | "D" 10 | ], 11 | "specialized": [ 12 | "SpecificSkills" 13 | ] 14 | }, 15 | { 16 | "employee_id": "7222247981", 17 | "name": "Oscar Coronado", 18 | "skills": [ 19 | "FA", 20 | "A", 21 | "B", 22 | "D" 23 | ], 24 | "specialized": [ 25 | "SpecificSkills" 26 | ] 27 | }, 28 | { 29 | "employee_id": "8875727446", 30 | "name": "Nestor Mejia", 31 | "skills": [ 32 | "FA", 33 | "TPR", 34 | "B", 35 | "D" 36 | ], 37 | "specialized": [ 38 | "SpecificSkills" 39 | ] 40 | }, 41 | { 42 | "employee_id": "6117206298", 43 | "name": "Marco Trujillo", 44 | "skills": [ 45 | "FA", 46 | "A", 47 | "B", 48 | "D" 49 | ], 50 | "specialized": [ 51 | "SpecificSkills" 52 | ] 53 | } 54 | ], 55 | "locations": [ 56 | { 57 | "location_id": "79558", 58 | "employee_id": "" 59 | }, 60 | { 61 | "location_id": "20865", 62 | "employee_id": "" 63 | }, 64 | { 65 | "location_id": "03749", 66 | "employee_id": "8875727446" 67 | }, 68 | { 69 | "location_id": "39485", 70 | "employee_id": "" 71 | }, 72 | { 73 | "location_id": "90706", 74 | "employee_id": "8875727446" 75 | } 76 | ], 77 | "jobs": [ 78 | { 79 | "job_duration": 3, 80 | "job_type": "D", 81 | "job_id": "x7f4bzqP", 82 | "expected_date": "2019-04-08", 83 | "location_id": "79558", 84 | "shipment_date": "2019-04-30" 85 | }, 86 | { 87 | "job_duration": 3, 88 | "job_type": "D", 89 | "job_id": "SZntYmCs", 90 | "expected_date": "2019-04-08", 91 | "location_id": "79558", 92 | "shipment_date": "2019-04-30" 93 | }, 94 | { 95 | "job_duration": 3, 96 | "job_type": "D", 97 | "job_id": "mefZUjzH", 98 | "expected_date": "2019-04-08", 99 | "location_id": "79558", 100 | "shipment_date": "2019-04-30" 101 | }, 102 | { 103 | "job_duration": 3, 104 | "job_type": "D", 105 | "job_id": "U2R8oIWr", 106 | "expected_date": "2019-04-08", 107 | "location_id": "79558", 108 | "shipment_date": "2019-04-30" 109 | }, 110 | { 111 | "job_duration": 3, 112 | "job_type": "D", 113 | "job_id": "pPjY3lwB", 114 | "expected_date": "2019-04-08", 115 | "location_id": "79558", 116 | "shipment_date": "2019-04-30" 117 | } 118 | ], 119 | "distances": [ 120 | { 121 | "hours": 1, 122 | "measure_point": "39485", 123 | "reference_point": "79558" 124 | }, 125 | { 126 | "hours": 1, 127 | "measure_point": "39485", 128 | "reference_point": "20865" 129 | }, 130 | { 131 | "hours": 1, 132 | "measure_point": "20865", 133 | "reference_point": "79558" 134 | }, 135 | { 136 | "hours": 1, 137 | "measure_point": "6117206298", 138 | "reference_point": "79558" 139 | }, 140 | { 141 | "hours": 1, 142 | "measure_point": "6117206298", 143 | "reference_point": "20865" 144 | }, 145 | { 146 | "hours": 1, 147 | "measure_point": "6117206298", 148 | "reference_point": "39485" 149 | }, 150 | { 151 | "hours": 1, 152 | "measure_point": "0230509700", 153 | "reference_point": "79558" 154 | }, 155 | { 156 | "hours": 1, 157 | "measure_point": "0230509700", 158 | "reference_point": "20865" 159 | }, 160 | { 161 | "hours": 1, 162 | "measure_point": "0230509700", 163 | "reference_point": "39485" 164 | }, 165 | { 166 | "hours": 1, 167 | "measure_point": "7222247981", 168 | "reference_point": "79558" 169 | }, 170 | { 171 | "hours": 1, 172 | "measure_point": "7222247981", 173 | "reference_point": "20865" 174 | }, 175 | { 176 | "hours": 1, 177 | "measure_point": "7222247981", 178 | "reference_point": "39485" 179 | }, 180 | { 181 | "hours": 13, 182 | "measure_point": "39485", 183 | "reference_point": "90706" 184 | }, 185 | { 186 | "hours": 13, 187 | "measure_point": "39485", 188 | "reference_point": "03749" 189 | }, 190 | { 191 | "hours": 13, 192 | "measure_point": "03749", 193 | "reference_point": "79558" 194 | }, 195 | { 196 | "hours": 13, 197 | "measure_point": "03749", 198 | "reference_point": "20865" 199 | }, 200 | { 201 | "hours": 1, 202 | "measure_point": "03749", 203 | "reference_point": "90706" 204 | }, 205 | { 206 | "hours": 13, 207 | "measure_point": "90706", 208 | "reference_point": "79558" 209 | }, 210 | { 211 | "hours": 13, 212 | "measure_point": "90706", 213 | "reference_point": "20865" 214 | }, 215 | { 216 | "hours": 13, 217 | "measure_point": "6117206298", 218 | "reference_point": "90706" 219 | }, 220 | { 221 | "hours": 13, 222 | "measure_point": "6117206298", 223 | "reference_point": "03749" 224 | }, 225 | { 226 | "hours": 13, 227 | "measure_point": "0230509700", 228 | "reference_point": "90706" 229 | }, 230 | { 231 | "hours": 13, 232 | "measure_point": "0230509700", 233 | "reference_point": "03749" 234 | }, 235 | { 236 | "hours": 13, 237 | "measure_point": "7222247981", 238 | "reference_point": "90706" 239 | }, 240 | { 241 | "hours": 13, 242 | "measure_point": "7222247981", 243 | "reference_point": "03749" 244 | }, 245 | { 246 | "hours": 13, 247 | "measure_point": "8875727446", 248 | "reference_point": "79558" 249 | }, 250 | { 251 | "hours": 13, 252 | "measure_point": "8875727446", 253 | "reference_point": "20865" 254 | }, 255 | { 256 | "hours": 1, 257 | "measure_point": "8875727446", 258 | "reference_point": "90706" 259 | }, 260 | { 261 | "hours": 1, 262 | "measure_point": "8875727446", 263 | "reference_point": "03749" 264 | }, 265 | { 266 | "hours": 13, 267 | "measure_point": "8875727446", 268 | "reference_point": "39485" 269 | } 270 | ], 271 | "blocked_times": [ 272 | { 273 | "job_type": "Meeting", 274 | "job_duration": 3, 275 | "employee_id": "7222247981", 276 | "requested_date": "2018-04-09", 277 | "blocked_id": "wonoXGV5" 278 | } 279 | ] 280 | } --------------------------------------------------------------------------------