├── .gitignore ├── docker-run.sh ├── Manager ├── functional_tests │ └── test.jl ├── unit_tests │ ├── test.jl │ ├── helpers.jl │ └── filters.jl ├── env.jl ├── messages.jl ├── server.jl ├── helpers.jl ├── tasking.jl ├── setter.jl ├── calculation.jl ├── fetching.jl └── filters.jl ├── StaffJoy ├── unit_tests │ ├── test.jl │ ├── week_test.jl │ ├── unassigned_test.jl │ ├── windowing_test.jl │ └── helpers_test.jl ├── functional_tests │ ├── test.jl │ ├── bifurcate_test.jl │ ├── unassigned_test.jl │ ├── day_test.jl │ ├── week_test.jl │ └── windowing_test.jl ├── test_data │ ├── delivery-unassigned.jl │ ├── day-prior-failure.jl │ ├── courier-bike-monday.jl │ ├── week-with-day-off.jl │ ├── consecutive-week.jl │ ├── consecutive-week-with-preceding.jl │ ├── courier-car-1.jl │ ├── courier_overscheduled.json │ ├── courier-bike-1.jl │ └── courier-incorrect-availability-length.jl ├── bifurcate.jl ├── windowing.jl ├── unassigned.jl ├── helpers.jl ├── serial_schedulers.jl ├── day.jl └── week.jl ├── dependencies.sh ├── .travis.yml ├── Dockerrun.aws.json ├── makefile ├── server.sh ├── Dockerfile ├── Vagrantfile ├── LICENSE ├── Manager.jl ├── jenkins-scheduler-deploy.sh ├── jenkins-scheduler-build.sh ├── StaffJoy.jl └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | .DS_Store 3 | .vagrant/ 4 | *.log 5 | -------------------------------------------------------------------------------- /docker-run.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | set -e 3 | cd /src/ 4 | bash server.sh 5 | -------------------------------------------------------------------------------- /Manager/functional_tests/test.jl: -------------------------------------------------------------------------------- 1 | function functional_test() 2 | Logging.info( "Manager Functional Tests passing. Good job.") 3 | end 4 | -------------------------------------------------------------------------------- /Manager/unit_tests/test.jl: -------------------------------------------------------------------------------- 1 | function unit_test() 2 | test_helpers() 3 | test_filters() 4 | Logging.info( "Manager Unit Tests passing. Good job.") 5 | end 6 | 7 | -------------------------------------------------------------------------------- /StaffJoy/unit_tests/test.jl: -------------------------------------------------------------------------------- 1 | function unit_test() 2 | helpers_test_unit() 3 | windowing_test_unit() 4 | unassigned_test_unit() 5 | week_test_unit() 6 | 7 | Logging.info("Scheduler Unit Tests passing. Good job.") 8 | end 9 | -------------------------------------------------------------------------------- /dependencies.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | set -e 3 | 4 | ## Use one or the other 5 | #julia -e 'Pkg.add("Gurobi")' 6 | julia -e 'Pkg.add("Cbc")' 7 | 8 | julia -e 'Pkg.add("JuMP")' 9 | julia -e 'Pkg.add("JSON")' 10 | julia -e 'Pkg.add("Logging")' 11 | julia -e 'Pkg.add("Requests")' 12 | julia -e 'Pkg.add("HttpServer")' 13 | -------------------------------------------------------------------------------- /StaffJoy/functional_tests/test.jl: -------------------------------------------------------------------------------- 1 | function functional_test() 2 | day_test_functional() 3 | windowing_test_functional() 4 | week_test_functional() 5 | unassigned_test_functional() 6 | bifurcate_test_functional() 7 | 8 | Logging.info("Scheduler Functional Tests passing. Good job.") 9 | end 10 | -------------------------------------------------------------------------------- /StaffJoy/functional_tests/bifurcate_test.jl: -------------------------------------------------------------------------------- 1 | function bifurcate_test_functional() 2 | employees = Dict() # no employees :-) 3 | ok, shifts = bifurcate_unassigned_scheduler(test_delivery_unassigned_env) 4 | @test week_sum_coverage(test_delivery_unassigned_env) * TEST_THRESHOLD > week_hours_scheduled(shifts) 5 | end 6 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | dist: trusty 2 | sudo: false 3 | language: julia 4 | os: 5 | - linux 6 | julia: 7 | - 0.3 8 | cache: 9 | directories: 10 | - $HOME/.julia/ 11 | env: 12 | global: 13 | JULIA_LOAD_PATH: "$TRAVIS_BUILD_DIR" 14 | install: 15 | - bash dependencies.sh 16 | script: 17 | - make test-unit 18 | -------------------------------------------------------------------------------- /StaffJoy/functional_tests/unassigned_test.jl: -------------------------------------------------------------------------------- 1 | function unassigned_test_functional() 2 | test_delivery_unassigned_functional() 3 | end 4 | 5 | function test_delivery_unassigned_functional() 6 | employees = Dict() # no employees :-) 7 | ok, weekly_schedule = schedule(employees, test_delivery_unassigned_env) 8 | @test ok 9 | end 10 | -------------------------------------------------------------------------------- /Dockerrun.aws.json: -------------------------------------------------------------------------------- 1 | { 2 | "AWSEBDockerrunVersion": "1", 3 | "Authentication": { 4 | "Bucket": "staffjoy-deploy", 5 | "Key": "docker.cfg" 6 | }, 7 | "Image": { 8 | "Name": "staffjoy/scheduler:TAG", 9 | "Update": "true" 10 | }, 11 | "Ports": [ 12 | { 13 | "ContainerPort": "80" 14 | } 15 | ], 16 | "Volumes": [ 17 | { 18 | "HostDirectory": "/var/app/mydb", 19 | "ContainerDirectory": "/etc/mysql" 20 | } 21 | ], 22 | "Logging": "/var/log/nginx" 23 | } 24 | -------------------------------------------------------------------------------- /makefile: -------------------------------------------------------------------------------- 1 | .PHONY: test 2 | test: 3 | make test-unit 4 | make test-functional 5 | test-unit: 6 | make test-scheduler-unit 7 | make test-manager-unit 8 | test-functional: 9 | make test-scheduler-functional 10 | make test-manager-functional 11 | test-scheduler-unit: 12 | julia -e 'using StaffJoy; unit_test()' 13 | test-manager-unit: 14 | julia -e 'using Manager; unit_test()' 15 | test-scheduler-functional: 16 | julia -e 'using StaffJoy; functional_test()' 17 | test-manager-functional: 18 | julia -e 'using Manager; functional_test()' 19 | -------------------------------------------------------------------------------- /server.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | 4 | # 1) Run julia in parallel mode 5 | # 2) Remove short log lines that are probably just new lines anyways 6 | # 3)Filter out stupid gurobi server capacity 7 | # 4) Add the environment to the log line 8 | # 5) Write it to scheduler.log, but also add 9 | 10 | julia -p `julia -e "print(Base.CPU_CORES)"` -e 'import Manager; Manager.run_server()' 2>&1 | while read a; do echo "staffjoy-scheduler-$ENV $a " | grep -v "Server capacity available" | grep -v "Not solved to optimality" | grep -v "Farkas proof" | grep -v "Gurobi reported infeasible or unbounded" | tee -a scheduler.log; done 11 | -------------------------------------------------------------------------------- /StaffJoy/functional_tests/day_test.jl: -------------------------------------------------------------------------------- 1 | function day_test_functional() 2 | test_courier_bike_monday() 3 | test_prior_failure() 4 | end 5 | 6 | function test_courier_bike_monday() 7 | ok, schedule = schedule_day(bike_monday_employees, bike_monday_env) 8 | @test ok == true 9 | @test day_sum_coverage(bike_monday_env) * TEST_THRESHOLD > day_hours_scheduled(schedule) 10 | end 11 | 12 | function test_prior_failure() 13 | ok, schedule = schedule_day(test_prior_failure_employees, test_prior_failure_env) 14 | @test ok == true 15 | @test day_sum_coverage(test_prior_failure_env) * TEST_THRESHOLD > day_hours_scheduled(schedule) 16 | end 17 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM ubuntu:14.04 2 | ENV DEBIAN_FRONTEND noninteractive 3 | ENV JULIA_LOAD_PATH="/vagrant/" 4 | ENV PATH "${PATH}:${GUROBI_HOME}/bin" 5 | 6 | # Bundle app source 7 | ADD . /src 8 | RUN rm -rf /src/*.log # precaution - mainly for dev 9 | RUN apt-get update --yes --force-yes 10 | RUN sudo apt-get install --yes --force-yes software-properties-common supervisor 11 | RUN cd /src/build/ && bash ubuntu-install.sh 12 | RUN ln -s /src/build/supervisor-app.conf /etc/supervisor/conf.d/ 13 | RUN rm -f /src/scheduler.log 14 | RUN touch /src/scheduler.log 15 | 16 | # Expose - note that load balancer terminates SSL 17 | EXPOSE 80 18 | 19 | # RUN 20 | CMD ["supervisord", "-n"] 21 | 22 | -------------------------------------------------------------------------------- /Manager/env.jl: -------------------------------------------------------------------------------- 1 | env_config = { 2 | "dev" => { 3 | "protocol" => "http", 4 | "host" => "dev.staffjoy.com", 5 | "api_key" => "staffjoydev", # This should be from a sudo user 6 | "sleep" => 10, # Seconds between fetching 7 | "port" => 8080, 8 | }, 9 | "stage" => { 10 | "protocol" => "https", 11 | "host" => "stage.staffjoy.com", 12 | "api_key" => get(ENV, "API_KEY", ""), 13 | "sleep" => get(ENV, "SLEEP", 60), 14 | "port" => 80, 15 | }, 16 | "prod" => { 17 | "protocol" => "https", 18 | "host" => "www.staffjoy.com", 19 | "api_key" => get(ENV, "API_KEY", ""), 20 | "sleep" => get(ENV, "SLEEP", 60), 21 | "port" => 80, 22 | }, 23 | } 24 | -------------------------------------------------------------------------------- /Manager/messages.jl: -------------------------------------------------------------------------------- 1 | # Send message to API 2 | function send_message(message, task) 3 | path = string( 4 | "/api/v1/organizations/", 5 | task["organization_id"], 6 | "/locations/", 7 | task["location_id"], 8 | "/roles/", 9 | task["role_id"], 10 | "/schedules/", 11 | task["schedule_id"], 12 | "/messages/", 13 | ) 14 | results = Requests.post( 15 | build_uri(config, path), 16 | json={"message" => message} 17 | ) 18 | 19 | if results.status != 201 20 | code = results.status 21 | reason = results.data 22 | Logging.err( "Unable to create message $message - status %code - response $reason") 23 | error() 24 | end 25 | Logging.info( "Sent message - $message") 26 | end 27 | -------------------------------------------------------------------------------- /StaffJoy/test_data/delivery-unassigned.jl: -------------------------------------------------------------------------------- 1 | test_delivery_unassigned_env = { 2 | # By hour starting at 8AM 3 | "coverage" => Array[ 4 | [3.0, 3.0, 3.0, 3.0, 3.0, 4.0, 4.0, 6.0, 6.0, 7.0, 7.0, 5.0, 2.0, 1.0], 5 | [2.0, 2.0, 4.0, 5.0, 3.0, 3.0, 4.0, 4.0, 7.0, 6.0, 5.0, 4.0, 2.0, 0.0], 6 | [3.0, 3.0, 5.0, 4.0, 5.0, 4.0, 5.0, 5.0, 7.0, 6.0, 6.0, 5.0, 3.0, 1.0], 7 | [2.0, 3.0, 3.0, 5.0, 5.0, 2.0, 5.0, 6.0, 7.0, 6.0, 6.0, 5.0, 3.0, 1.0], 8 | [3.0, 4.0, 4.0, 3.0, 5.0, 4.0, 6.0, 5.0, 6.0, 8.0, 5.0, 4.0, 4.0, 3.0], 9 | [3.0, 4.0, 5.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 6.0, 6.0, 5.0, 2.0, 2.0], 10 | [3.0, 4.0, 5.0, 5.0, 5.0, 4.0, 6.0, 6.0, 11.0, 8.0, 6.0, 4.0, 2.0, 0.0] 11 | ], 12 | "cycle_length" => 14, 13 | "shift_time_min" => 4, 14 | "shift_time_max" => 8, 15 | "time_between_coverage" => (10), 16 | } 17 | -------------------------------------------------------------------------------- /Manager/server.jl: -------------------------------------------------------------------------------- 1 | function run_server() 2 | # Start health check - necessary for AWS 3 | @async begin 4 | http = HttpHandler() do req::Request, res::Response 5 | Response(string(JSON.json({ 6 | "status" =>1, 7 | "env" => env, 8 | "hello" => "world", 9 | }))) 10 | end 11 | http.events["listen"] = (port) -> Logging.info("Health check listening on port $port") 12 | 13 | server = Server(http) 14 | run(server, config["port"]) 15 | end 16 | 17 | Logging.info("Starting server") 18 | while true 19 | task = get_task() 20 | if task != nothing 21 | run_task(task) 22 | mark_task_done(task) 23 | else 24 | Logging.debug("No task. Sleeping.") 25 | sleep(config["sleep"]) 26 | end 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /Vagrantfile: -------------------------------------------------------------------------------- 1 | # -*- mode: ruby -*- 2 | # vi: set ft=ruby : 3 | 4 | VAGRANTFILE_API_VERSION = "2" 5 | Vagrant.configure(VAGRANTFILE_API_VERSION) do |config| 6 | config.vm.box = "trusty-amd64" 7 | config.vm.box_url = "http://cloud-images.ubuntu.com/vagrant/trusty/current/trusty-server-cloudimg-amd64-vagrant-disk1.box" 8 | config.vm.network :private_network, ip: "192.168.69.70" 9 | config.vm.synced_folder ".", "/vagrant" 10 | config.vm.provider :virtualbox do |vb| 11 | vb.customize ["modifyvm", :id, "--memory", "1500"] 12 | vb.customize ["modifyvm", :id, "--natdnshostresolver1", "on"] 13 | end 14 | 15 | config.vm.provision "shell", privileged: false, inline: "cd /vagrant/build/ && bash ubuntu-install.sh" 16 | # Docker for dev purposes 17 | config.vm.provision "shell", inline: "curl -sSL https://get.docker.com/ | sudo sh" 18 | config.vm.provision "shell", inline: "echo 'export JULIA_LOAD_PATH=\"/vagrant/\"' >> /etc/profile" 19 | config.vm.provision "shell", inline: "echo 'export ENV=\"dev\"' >> /etc/profile" 20 | 21 | end 22 | -------------------------------------------------------------------------------- /Manager/unit_tests/helpers.jl: -------------------------------------------------------------------------------- 1 | function test_helpers() 2 | test_build_uri() 3 | test_days() 4 | end 5 | 6 | function test_build_uri() 7 | test_config = { 8 | "protocol" => "http", 9 | "host" => "dev.staffjoy.com", 10 | "api_key" => "staffjoydev", # This should be from a sudo user 11 | "pagerduty" => false, # Alert on issues? 12 | "pagerduty_key" => "", # API key for triggering an alert 13 | "sleep" => 10, # Seconds between fetching 14 | } 15 | 16 | path = "/home" 17 | expected = "http://staffjoydev:@dev.staffjoy.com/home" 18 | actual = build_uri(test_config, path) 19 | @test actual == expected 20 | end 21 | 22 | function test_days() 23 | # Given a start day, get days in the correct order 24 | input = {"day_week_starts" => "tuesday"} 25 | expected = [ 26 | "tuesday", 27 | "wednesday", 28 | "thursday", 29 | "friday", 30 | "saturday", 31 | "sunday", 32 | "monday", 33 | ] 34 | 35 | actual = days(input) 36 | @test expected == actual 37 | end 38 | -------------------------------------------------------------------------------- /Manager/helpers.jl: -------------------------------------------------------------------------------- 1 | function build_uri(config, path) 2 | # API basic 3 | # http://username:password@dev.staffjoy.com/path 4 | # Note that our api keys are username and there is no password, so 5 | # http://apikey:@dev.staffjoy.com/path 6 | 7 | return string(config["protocol"], "://", config["api_key"], ":@", config["host"], path) 8 | end 9 | 10 | function days(organization) 11 | DAYS = [ 12 | "monday", 13 | "tuesday", 14 | "wednesday", 15 | "thursday", 16 | "friday", 17 | "saturday", 18 | "sunday", 19 | ] 20 | 21 | start_day = organization["day_week_starts"] 22 | start_index = findfirst(DAYS, start_day) 23 | 24 | if start_index == 0 25 | Logging.err( "Unable to match organization start day") 26 | error() 27 | end 28 | 29 | output = Any[] 30 | for i in start_index:(start_index + length(DAYS) - 1) 31 | push!( 32 | output, 33 | # Stupid 1-indexing 34 | DAYS[(i - 1) % length(DAYS)+ 1] 35 | ) 36 | end 37 | return output 38 | end 39 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2015-2017 StaffJoy, Inc. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /StaffJoy/functional_tests/week_test.jl: -------------------------------------------------------------------------------- 1 | function week_test_functional() 2 | test_courier_bike_1() 3 | test_courier_car_1() 4 | end 5 | 6 | function test_courier_bike_1() 7 | info("STARTING COURIER BIKE 1") 8 | ok, weekly_schedule = schedule(test_bike_week_1_employees, test_bike_week_1_env) 9 | @test ok == true 10 | @test week_sum_coverage(test_bike_week_1_env) * TEST_THRESHOLD > week_hours_scheduled(weekly_schedule) 11 | info("ENDING COURIER BIKE 1") 12 | 13 | end 14 | 15 | function test_courier_car_1() 16 | info("STARTING COURIER CAR 1") 17 | ok, weekly_schedule = schedule(test_courier_car_1_employees, test_courier_car_1_env) 18 | @test ok == true 19 | @test week_sum_coverage(test_courier_car_1_env) * TEST_THRESHOLD > week_hours_scheduled(weekly_schedule) 20 | info("ENDING COURIER CAR 1") 21 | end 22 | 23 | function test_courier_incorrect_availability() 24 | info("STARTING COURIER INCORRECT AVAILABILITY LENGTH") 25 | ok, _ = schedule(test_bike_incorrect_availability_length_employees, test_bike_incorrect_availability_length_env) 26 | @test ok != true 27 | info("ENDING COURIER INCORRECT AVAILABILITY LENGTH") 28 | end 29 | -------------------------------------------------------------------------------- /StaffJoy/test_data/day-prior-failure.jl: -------------------------------------------------------------------------------- 1 | test_prior_failure_env = { 2 | "cycle_length" => 12, 3 | "coverage"=> [3,4,5,5,5,5,5,5,4,4,2,2] 4 | } 5 | 6 | test_prior_failure_employees = { 7 | "Jaeger"=>{ 8 | "max"=>9, 9 | "min"=>3, 10 | "availability"=>[1,1,1,1,1,1,1,1,1,1,1,1] 11 | }, 12 | "Damian"=>{ 13 | "max"=>8, 14 | "min"=>3, 15 | "availability"=>[1,1,1,1,1,1,1,1,1,1,1,1] 16 | }, 17 | "Patrick"=>{ 18 | "max"=>8, 19 | "min"=>3, 20 | "availability"=>[1,1,1,1,1,1,1,1,1,1,1,1] 21 | }, 22 | "Rufus"=>{ 23 | "max"=>8, 24 | "min"=>3, 25 | "availability"=>[1,1,1,1,1,1,1,1,1,1,1,1] 26 | }, 27 | "Shaun"=>{ 28 | "max"=>8, 29 | "min"=>3, 30 | "availability"=>[1,1,1,1,1,1,1,1,1,1,1,1] 31 | }, 32 | "Douglas"=>{ 33 | "max"=>8, 34 | "min"=>3, 35 | "availability"=>[1,1,1,1,1,1,1,1,1,1,1,1] 36 | }, 37 | "Leonardo"=>{ 38 | "max"=>8, 39 | "min"=>3, 40 | #"availability"=>[1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,0.0] 41 | "availability"=>[1,1,1,1,1,1,1,1,1,1,1,0], 42 | }, 43 | } 44 | 45 | 46 | 47 | -------------------------------------------------------------------------------- /Manager/tasking.jl: -------------------------------------------------------------------------------- 1 | #= 2 | This file handles fetching data from the StaffJoy API, parsing it, running a calculation, and sending it back to StaffJoy. 3 | =# 4 | import StaffJoy 5 | 6 | function get_task() 7 | results = Requests.post(build_uri(config, "/api/v1/tasking/")) 8 | if results.status == 404 9 | return nothing 10 | elseif results.status != 200 11 | # Bad request? Internal error? This is a pageable offense! 12 | code = results.status 13 | Logging.warn("Tasking API Down - code $code") 14 | return nothing 15 | end 16 | 17 | # Return task 18 | task = JSON.parse(Requests.text(results)) 19 | Logging.info("Task Received - $task") 20 | # Task Fields: schedule_id, role_id, location_id, organization_id 21 | return task 22 | end 23 | 24 | function mark_task_done(task) 25 | path = string("/api/v1/tasking/", task["schedule_id"]) 26 | results = Requests.delete(build_uri(config, path)) 27 | if results.status != 204 # 204 is "success / empty response" 28 | # Bad request? Internal error? This is a pageable offense! 29 | code = results.status 30 | schedule = task["schedule_id"] 31 | Logging.err( "Failed to mark schedule $schedule as done. Code $code") 32 | error() 33 | end 34 | 35 | Logging.info( "Task Completed - $task") 36 | end 37 | -------------------------------------------------------------------------------- /StaffJoy/functional_tests/windowing_test.jl: -------------------------------------------------------------------------------- 1 | function windowing_test_functional() 2 | test_windowing_end_to_end() 3 | end 4 | 5 | function test_windowing_end_to_end() 6 | # Goal: Just run a schedule that needs a window and make sure it comes out as expected 7 | env = { 8 | "shift_time_min" => 3, 9 | "shift_time_max" => 8, 10 | "coverage" => Array[ 11 | [0, 0, 1, 1, 1, 1, 1, 0, 0], 12 | [0, 0, 0, 1, 1, 1, 1, 1, 0], 13 | ], 14 | "time_between_coverage" => 12, 15 | "intershift" => 12, # Time periods between shifts # only serve 1 shift/day 16 | } 17 | 18 | employees = { 19 | "Lenny" => { 20 | "hours_min" => 6, # hours worked over simulation 21 | "hours_max" => 16, # hours worked over simulation 22 | "shift_time_min" => 3, 23 | "shift_time_max" => 8, 24 | "shift_count_min" => 1, 25 | "shift_count_max" => 2, 26 | "availability" => Array[ 27 | [ones(Int, 8)], 28 | [ones(Int, 8)], 29 | ], 30 | }, 31 | } 32 | ok, s = schedule(employees, env) 33 | @test ok 34 | 35 | # Check that the offsets weren't goofy :-) 36 | expected = {"Lenny"=>{{"length"=>5,"day"=>1,"start"=>3},{"length"=>5,"day"=>2,"start"=>4}}} 37 | @test expected == s 38 | end 39 | -------------------------------------------------------------------------------- /Manager.jl: -------------------------------------------------------------------------------- 1 | module Manager 2 | 3 | using Base.Test: @test 4 | using HttpServer 5 | import Requests 6 | import StaffJoy 7 | import JSON 8 | import Logging 9 | 10 | export run_server, unit_test, functional_test 11 | 12 | if "ENV" in keys(ENV) && ( 13 | ENV["ENV"] == "stage" || ENV["ENV"] == "prod" 14 | ) 15 | env = ENV["ENV"] 16 | Logging.configure(level=Logging.INFO) 17 | else 18 | Logging.configure(level=Logging.DEBUG) 19 | end 20 | 21 | # Boot Script 22 | include("Manager/env.jl") 23 | if "ENV" in keys(ENV) 24 | env = ENV["ENV"] 25 | if !(env in keys(env_config)) 26 | Logging.critical("Environment $env is invalid") 27 | end 28 | else 29 | env = "dev" 30 | end 31 | 32 | # We start demand at 4AM 33 | TIME_OFFSET = 3 34 | 35 | Logging.info("Loading environment $env") 36 | config = env_config[env] 37 | 38 | # Setup 39 | include("Manager/tasking.jl") 40 | include("Manager/server.jl") 41 | include("Manager/calculation.jl") 42 | include("Manager/fetching.jl") 43 | include("Manager/setter.jl") 44 | include("Manager/helpers.jl") 45 | include("Manager/filters.jl") 46 | include("Manager/messages.jl") 47 | 48 | # Unit Tests 49 | include("Manager/unit_tests/helpers.jl") 50 | include("Manager/unit_tests/filters.jl") 51 | include("Manager/unit_tests/test.jl") 52 | 53 | # Functional Tests 54 | include("Manager/functional_tests/test.jl") 55 | 56 | end#module 57 | -------------------------------------------------------------------------------- /StaffJoy/test_data/courier-bike-monday.jl: -------------------------------------------------------------------------------- 1 | bike_monday_env = { 2 | "coverage" => [3, 4, 5 * ones(Int, 6), 4 * ones(Int, 2), 2 * ones(Int, 2)], 3 | "cycle_length" => 12, 4 | } 5 | 6 | # Coverage requires 49 man hours 7 | 8 | bike_monday_employees = { 9 | "Cody" => { 10 | "min" => 3, 11 | "max" => 8, 12 | "availability" => ones(Int, 12), 13 | }, 14 | "Damian" => { 15 | "min" => 3, 16 | "max" => 8, 17 | "availability" => ones(Int, 12), 18 | }, 19 | "Douglas" => { 20 | "min" => 3, 21 | "max" => 8, 22 | "availability" => ones(Int, 12), 23 | }, 24 | "Desmond" => { 25 | "min" => 3, 26 | "max" => 8, 27 | "availability" => ones(Int, 12), 28 | }, 29 | "Dylan" => { 30 | "min" => 3, 31 | "max" => 8, 32 | "availability" => [ 33 | ones(Int, 7), zeros(Int, 5), 34 | ], 35 | }, 36 | "Jaeger" => { 37 | "min" => 3, 38 | "max" => 8, 39 | "availability" => ones(Int, 12), 40 | }, 41 | "Leonardo" => { 42 | "min" => 3, 43 | "max" => 8, 44 | "availability" => [ 45 | ones(Int, 7), zeros(Int, 5), 46 | ], 47 | }, 48 | "Shaun" => { 49 | "min" => 3, # hours worked over simulation 50 | "max" => 8, # hours worked over simulation 51 | "availability" => [ 52 | ones(Int, 12), 53 | ], 54 | }, 55 | } 56 | -------------------------------------------------------------------------------- /StaffJoy/bifurcate.jl: -------------------------------------------------------------------------------- 1 | function bifurcate_unassigned_scheduler(env) 2 | # Split a problem into two sub problems to increase speed. 3 | # Hypthetically could be called recursively. 4 | child_ceil = deepcopy(env) 5 | child_floor = deepcopy(env) 6 | for day in 1:length(env["coverage"]) 7 | for t in 1:length(env["coverage"][day]) 8 | child_ceil["coverage"][day][t] = ceil(env["coverage"][day][t] / 2) 9 | 10 | child_floor["coverage"][day][t] = floor(env["coverage"][day][t] / 2) 11 | if child_floor["coverage"][day][t] == 0 && env["coverage"][day][t] != 0 12 | child_floor["coverage"][day][t] = 1 13 | end 14 | end 15 | end 16 | 17 | # Run separately 18 | Logging.info("Starting ceiling bifurcation child") 19 | ok, shifts_ceil = run_schedule_unassigned(child_ceil) 20 | if !ok 21 | return ok, Dict() 22 | end 23 | 24 | Logging.info( "Starting floor bifurcation child") 25 | ok, shifts_floor = run_schedule_unassigned(child_floor) 26 | if !ok 27 | return ok, Dict() 28 | end 29 | 30 | # Merge shifts 31 | output_shifts = deepcopy(shifts_ceil) 32 | for name in keys(shifts_floor) 33 | if !(name in keys(output_shifts)) 34 | output_shifts[name] = Any[] 35 | end 36 | for shift in shifts_floor[name] 37 | push!(output_shifts[name], shift) 38 | end 39 | end 40 | return true, output_shifts 41 | end 42 | -------------------------------------------------------------------------------- /jenkins-scheduler-deploy.sh: -------------------------------------------------------------------------------- 1 | set -e 2 | export name="master-${BUILD_NUMBER}" 3 | echo "Beginning the deploy of build ${BUILD_NUMBER}" 4 | 5 | tag="master-${BUILD_NUMBER}" 6 | 7 | echo "" 8 | echo "DEPLOYING $tag" 9 | echo "" 10 | 11 | # Deploy to prod 12 | aws elasticbeanstalk update-environment \ 13 | --environment-name "scheduler-prod" \ 14 | --version-label "$tag" 15 | 16 | # # Polling to see whether deploy is done 17 | deploystart=$(date +%s) 18 | timeout=1000 # Seconds to wait before error 19 | threshhold=$((deploystart + timeout)) 20 | while true; do 21 | # Check for timeout 22 | timenow=$(date +%s) 23 | if [[ "$timenow" > "$threshhold" ]]; then 24 | echo "Timeout - $timeout seconds elapsed" 25 | exit 1 26 | fi 27 | 28 | # See what's deployed 29 | version=`aws elasticbeanstalk describe-environments --application-name "staffjoy-scheduler" --environment-name "scheduler-prod" --query "Environments[*].VersionLabel" --output text` 30 | status=`aws elasticbeanstalk describe-environments --application-name "staffjoy-scheduler" --environment-name "scheduler-prod" --query "Environments[*].Status" --output text` 31 | 32 | if [ "$version" != "$tag" ]; then 33 | echo "Tag not updated (currently $version). Waiting." 34 | sleep 10 35 | continue 36 | fi 37 | if [ "$status" != "Ready" ]; then 38 | echo "System not Ready -it's $status. Waiting." 39 | sleep 10 40 | continue 41 | fi 42 | break 43 | done 44 | 45 | -------------------------------------------------------------------------------- /StaffJoy/test_data/week-with-day-off.jl: -------------------------------------------------------------------------------- 1 | # A hypothetical env where one day requires no coverage 2 | test_day_off_env = { 3 | "coverage" => Array[ 4 | [2*ones(Int,8)], 5 | [2*ones(Int,8)], 6 | [zeros(Int,8)], 7 | [2*ones(Int,8)], 8 | ], 9 | } 10 | 11 | test_day_off_employees = { 12 | "a" => { 13 | "hours_min" => 15, 14 | "hours_max" => 17, 15 | "shift_count_min" => 2, 16 | "shift_count_max" => 2, 17 | "availability" => Array[ 18 | [ones(Int,8)], 19 | [ones(Int,8)], 20 | [ones(Int,8)], 21 | [ones(Int,8)], 22 | ], 23 | "longest_availability" => 8*ones(Int, 8), 24 | }, 25 | "b" => { 26 | "hours_min" => 15, 27 | "hours_max" => 17, 28 | "shift_count_min" => 2, 29 | "shift_count_max" => 2, 30 | "availability" => Array[ 31 | [ones(Int,8)], 32 | [ones(Int,8)], 33 | [ones(Int,8)], 34 | [ones(Int,8)], 35 | ], 36 | "longest_availability" => 8*ones(Int, 8), 37 | }, 38 | "c" => { 39 | "hours_min" => 15, 40 | "hours_max" => 17, 41 | "shift_count_min" => 2, 42 | "shift_count_max" => 2, 43 | "availability" => Array[ 44 | [ones(Int,8)], 45 | [ones(Int,8)], 46 | [ones(Int,8)], 47 | [ones(Int,8)], 48 | ], 49 | "longest_availability" => 8*ones(Int, 8), 50 | }, 51 | } 52 | 53 | -------------------------------------------------------------------------------- /Manager/setter.jl: -------------------------------------------------------------------------------- 1 | # Send shifts to API 2 | function set_shifts(shifts, task, organization) 3 | path = string( 4 | "/api/v1/organizations/", 5 | task["organization_id"], 6 | "/locations/", 7 | task["location_id"], 8 | "/roles/", 9 | task["role_id"], 10 | "/schedules/", 11 | task["schedule_id"], 12 | "/shifts/", 13 | ) 14 | for e in keys(shifts) 15 | for shift in shifts[e] 16 | if StaffJoy.is_unassigned_shift(e) 17 | data = { 18 | "user_id" => 0, # This line could be omitted 19 | "day" => shift["day"], 20 | "start" => shift["start"] + TIME_OFFSET, 21 | "length" => shift["length"], 22 | } 23 | else 24 | data = { 25 | "user_id" => e, # Flask can convert strings to int 26 | "day" => shift["day"], 27 | "start" => shift["start"] + TIME_OFFSET, 28 | "length" => shift["length"], 29 | } 30 | end 31 | results = Requests.post(build_uri(config, path), json=data) 32 | if results.status != 201 33 | code = results.status 34 | message = results.data 35 | Logging.err("Unable to create shift $data - status %code - message $message") 36 | error() 37 | end 38 | Logging.info("Created shift - $data") 39 | end 40 | end 41 | end 42 | 43 | -------------------------------------------------------------------------------- /StaffJoy/test_data/consecutive-week.jl: -------------------------------------------------------------------------------- 1 | # A hypothetical env where consecutive days off are DEFINITELY possible 2 | test_consecutive_env = { 3 | "coverage" => Array[ 4 | [2*ones(Int,8)], 5 | [2*ones(Int,8)], 6 | [2*ones(Int,8)], 7 | [2*ones(Int,8)], 8 | ], 9 | } 10 | 11 | test_consecutive_employees = { 12 | "a" => { 13 | "hours_min" => 15, 14 | "hours_max" => 17, 15 | "shift_count_min" => 2, 16 | "shift_count_max" => 2, 17 | "availability" => Array[ 18 | [ones(Int,8)], 19 | [ones(Int,8)], 20 | [ones(Int,8)], 21 | [ones(Int,8)], 22 | ], 23 | "longest_availability" => 8*ones(Int, 8), 24 | }, 25 | "b" => { 26 | "hours_min" => 15, 27 | "hours_max" => 17, 28 | "shift_count_min" => 2, 29 | "shift_count_max" => 2, 30 | "availability" => Array[ 31 | [ones(Int,8)], 32 | [ones(Int,8)], 33 | [ones(Int,8)], 34 | [ones(Int,8)], 35 | ], 36 | "longest_availability" => 8*ones(Int, 8), 37 | }, 38 | "c" => { 39 | "hours_min" => 15, 40 | "hours_max" => 17, 41 | "shift_count_min" => 2, 42 | "shift_count_max" => 2, 43 | "availability" => Array[ 44 | [ones(Int,8)], 45 | [ones(Int,8)], 46 | [ones(Int,8)], 47 | [ones(Int,8)], 48 | ], 49 | "longest_availability" => 8*ones(Int, 8), 50 | }, 51 | "d" => { 52 | "hours_min" => 15, 53 | "hours_max" => 17, 54 | "shift_count_min" => 2, 55 | "shift_count_max" => 2, 56 | "availability" => Array[ 57 | [ones(Int,8)], 58 | [ones(Int,8)], 59 | [ones(Int,8)], 60 | [ones(Int,8)], 61 | ], 62 | "longest_availability" => 8*ones(Int, 8), 63 | }, 64 | } 65 | 66 | -------------------------------------------------------------------------------- /StaffJoy/windowing.jl: -------------------------------------------------------------------------------- 1 | # Preprocessing to narrow what we schedule 2 | 3 | # Apply this to coverage 4 | function get_window(data) 5 | start_index = length(data[1]) 6 | end_index = 1 7 | 8 | # Scan 1: Find indices 9 | for datum in data 10 | first_nonzero = 1 11 | last_nonzero = length(datum) 12 | 13 | for i=1:length(datum) 14 | first_nonzero = i 15 | if datum[i] != 0 16 | break 17 | end 18 | end 19 | 20 | for j=length(datum):-1:1 21 | last_nonzero = j 22 | if datum[j] != 0 23 | break 24 | end 25 | end 26 | 27 | # Avoid case of all zeros 28 | if first_nonzero < last_nonzero 29 | if first_nonzero < start_index 30 | start_index = first_nonzero 31 | end 32 | if last_nonzero > end_index 33 | end_index = last_nonzero 34 | end 35 | end 36 | end 37 | 38 | # Check for valid window 39 | if start_index >= end_index 40 | # No window detected 41 | return data 42 | end 43 | 44 | # time_delta is used for adjusting time_between_shifts 45 | time_delta = (start_index - 1) + (length(data[1]) - end_index) 46 | # time_delta is what should be applied to env 47 | return start_index, end_index, time_delta 48 | end 49 | 50 | # Input can be availability, coverage, etc - by the week 51 | function apply_window(input, start_index, end_index) 52 | input = deepcopy(input) # passed by reference 53 | for t in 1:length(input) 54 | input[t] = input[t][start_index:end_index] 55 | end 56 | return input 57 | end 58 | 59 | # Remove the window from shifts. 60 | function remove_window(shifts, start_index) 61 | shifts = deepcopy(shifts) 62 | offset = start_index - 1 # one-indexed language! 63 | for e in keys(shifts) 64 | for shift in shifts[e] 65 | shift["start"] += offset 66 | end 67 | end 68 | return shifts 69 | end 70 | -------------------------------------------------------------------------------- /jenkins-scheduler-build.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | 4 | docker pull ubuntu:14.04 5 | file="Dockerrun.aws.json" 6 | tag="master-${BUILD_NUMBER}" 7 | repo="staffjoy/scheduler" 8 | s3Bucket="staffjoy-deploy" 9 | s3Path="scheduler/$tag/" 10 | s3Key="$s3Path$file" 11 | 12 | # Add version 13 | sed -i "s/TAG/$tag/" $file 14 | 15 | docker build -t $repo:$tag . 16 | 17 | # Run tests 18 | docker run -t $repo:$tag /bin/bash -c 'cd /src/ && make test' 19 | 20 | docker push $repo:$tag 21 | 22 | # Add the Dockerrun to S3 so that beanstalk can access it 23 | aws s3 cp $file s3://$s3Bucket/$s3Path 24 | 25 | # Create version 26 | aws elasticbeanstalk create-application-version \ 27 | --application-name staffjoy-scheduler \ 28 | --version-label "$tag" \ 29 | --source-bundle "{\"S3Bucket\":\"$s3Bucket\",\"S3Key\":\"$s3Key\"}" 30 | 31 | # Deploy to stage 32 | aws elasticbeanstalk update-environment \ 33 | --environment-name "scheduler-stage" \ 34 | --version-label "$tag" 35 | 36 | # Polling to see whether deploy is done 37 | deploystart=$(date +%s) 38 | timeout=1000 # Seconds to wait before error 39 | threshhold=$((deploystart + timeout)) 40 | while true; do 41 | # Check for timeout 42 | timenow=$(date +%s) 43 | if [[ "$timenow" > "$threshhold" ]]; then 44 | echo "Timeout - $timeout seconds elapsed" 45 | exit 1 46 | fi 47 | 48 | # See what's deployed 49 | version=`aws elasticbeanstalk describe-environments --application-name "staffjoy-scheduler" --environment-name "scheduler-stage" --query "Environments[*].VersionLabel" --output text` 50 | status=`aws elasticbeanstalk describe-environments --application-name "staffjoy-scheduler" --environment-name "scheduler-stage" --query "Environments[*].Status" --output text` 51 | 52 | if [ "$version" != "$tag" ]; then 53 | echo "Tag not updated (currently $version). Waiting." 54 | sleep 10 55 | continue 56 | fi 57 | if [ "$status" != "Ready" ]; then 58 | echo "System not Ready -it's $status. Waiting." 59 | sleep 10 60 | continue 61 | fi 62 | break 63 | done -------------------------------------------------------------------------------- /StaffJoy/test_data/consecutive-week-with-preceding.jl: -------------------------------------------------------------------------------- 1 | test_consecutive_with_preceding_env = { 2 | "coverage" => Array[ 3 | [2*ones(Int,8)], 4 | [3*ones(Int,8)], 5 | [3*ones(Int,8)], 6 | [2*ones(Int,8)], 7 | ], 8 | } 9 | 10 | test_consecutive_with_preceding_employees = { 11 | "a" => { 12 | "hours_min" => 15, 13 | "hours_max" => 17, 14 | "shift_count_min" => 2, 15 | "shift_count_max" => 2, 16 | "availability" => Array[ 17 | [ones(Int,8)], 18 | [ones(Int,8)], 19 | [ones(Int,8)], 20 | [ones(Int,8)], 21 | ], 22 | "longest_availability" => 8*ones(Int, 4), 23 | "worked_day_preceding_week" => true, 24 | }, 25 | "b" => { 26 | "hours_min" => 15, 27 | "hours_max" => 17, 28 | "shift_count_min" => 2, 29 | "shift_count_max" => 2, 30 | "availability" => Array[ 31 | [ones(Int,8)], 32 | [ones(Int,8)], 33 | [ones(Int,8)], 34 | [ones(Int,8)], 35 | ], 36 | "longest_availability" => 8*ones(Int, 4), 37 | "worked_day_preceding_week" => true, 38 | }, 39 | "c" => { 40 | "hours_min" => 15, 41 | "hours_max" => 17, 42 | "shift_count_min" => 2, 43 | "shift_count_max" => 2, 44 | "availability" => Array[ 45 | [ones(Int,8)], 46 | [ones(Int,8)], 47 | [ones(Int,8)], 48 | [ones(Int,8)], 49 | ], 50 | "longest_availability" => 8*ones(Int, 4), 51 | "worked_day_preceding_week" => true, 52 | }, 53 | "d" => { 54 | "hours_min" => 15, 55 | "hours_max" => 17, 56 | "shift_count_min" => 2, 57 | "shift_count_max" => 2, 58 | "availability" => Array[ 59 | [ones(Int,8)], 60 | [ones(Int,8)], 61 | [ones(Int,8)], 62 | [ones(Int,8)], 63 | ], 64 | "longest_availability" => 8*ones(Int, 4), 65 | "worked_day_preceding_week" => true, 66 | }, 67 | "e" => { 68 | # must be scheduled days 2->3, but gets consec day off due to 69 | # having worked the day preceding the week 70 | "hours_min" => 15, 71 | "hours_max" => 17, 72 | "shift_count_min" => 2, 73 | "shift_count_max" => 2, 74 | "availability" => Array[ 75 | [ones(Int,8)], 76 | [ones(Int,8)], 77 | [ones(Int,8)], 78 | [zeros(Int,8)], 79 | ], 80 | "longest_availability" => [8, 8, 8, 0], 81 | "worked_day_preceding_week" => false, 82 | }, 83 | } 84 | 85 | -------------------------------------------------------------------------------- /Manager/calculation.jl: -------------------------------------------------------------------------------- 1 | # This is the file that executes on tasks, aka where the magic happens 2 | 3 | function run_task(task) 4 | organization = fetch_organization(task) 5 | schedule = fetch_schedule(task) 6 | 7 | if organization["plan"] == "per-seat-v1" 8 | workers_raw = fetch_workers(task) 9 | api_availability = fetch_availability(task) 10 | workers = fill_missing_availability(schedule, api_availability, workers_raw, organization, task) 11 | workers = downgrade_availability(schedule, workers, organization, task) 12 | else # Unassigned shifts mode 13 | workers = Dict() 14 | api_availability = Dict() 15 | end 16 | 17 | shifts = run_calculation(schedule, workers, organization) 18 | set_shifts(shifts, task, organization) 19 | end 20 | 21 | function run_calculation(schedule, workers_input, organization) 22 | # Do some merging into StaffJoy.jl format and run calculation 23 | 24 | env = deepcopy(schedule) 25 | 26 | # build coverage 27 | coverage = Array[] 28 | for day in days(organization) 29 | push!(coverage, schedule["demand"][day]) 30 | end 31 | env["coverage"] = coverage 32 | 33 | env["shift_time_min"] = organization["min_shift_length"] 34 | env["shift_time_max"] = organization["max_shift_length"] 35 | env["intershift"] = organization["hours_between_shifts"] 36 | 37 | # time between coverage is 0 in manager by definition. 38 | # Windowing happens at scheduler level. 39 | env["time_between_coverage"] = 0 40 | 41 | 42 | workers = Dict() 43 | for worker in workers_input 44 | w = Dict() 45 | w["hours_min"] = int(worker["min_hours_per_week"]) 46 | w["hours_max"] = int(worker["max_hours_per_week"]) 47 | w["shift_time_min"] = int(worker["min_shift_length"]) 48 | w["shift_time_max"] = int(worker["max_shift_length"]) 49 | w["shift_count_min"] = int(worker["min_shifts_per_week"]) 50 | w["shift_count_max"] = int(worker["max_shifts_per_week"]) 51 | # Offset from absolute time to the 4AM thing 52 | if "no_shifts_after" in keys(worker) 53 | w["no_shifts_after"] = int(worker["no_shifts_after"]) - TIME_OFFSET 54 | end 55 | 56 | # Convert availability to array of arrays - no day of week 57 | w["availability"] = Array[] 58 | for day in days(organization) 59 | push!(w["availability"], worker["availability"][day]) 60 | end 61 | 62 | workers[string(worker["id"])] = w 63 | end 64 | ok, shifts = StaffJoy.schedule(workers, env) 65 | if !ok 66 | Logging.err("Schedule failed") 67 | error() 68 | end 69 | return shifts 70 | end 71 | -------------------------------------------------------------------------------- /Manager/fetching.jl: -------------------------------------------------------------------------------- 1 | # Files for fetching stuff from the StaffJoy API 2 | 3 | function fetch_organization(task) 4 | path = string("/api/v1/organizations/", task["organization_id"]) 5 | results = Requests.get(build_uri(config, path)) 6 | if results.status != 200 7 | code = results.status 8 | err("Organization API Down - code $code") 9 | end 10 | organization = JSON.parse(Requests.text(results)) 11 | Logging.info( "Organization Info Received") 12 | return organization["data"] 13 | end 14 | 15 | function fetch_schedule(task) 16 | path = string( 17 | "/api/v1/organizations/", 18 | task["organization_id"], 19 | "/locations/", 20 | task["location_id"], 21 | "/roles/", 22 | task["role_id"], 23 | "/schedules/", 24 | task["schedule_id"], 25 | ) 26 | results = Requests.get(build_uri(config, path)) 27 | 28 | if results.status != 200 29 | code = results.status 30 | Logging.err( "schedule API down - code $code") 31 | error() 32 | end 33 | schedule = JSON.parse(Requests.text(results)) 34 | Logging.info("Schedule Info Received") 35 | return schedule["data"] 36 | end 37 | 38 | function fetch_workers(task) 39 | path = string( 40 | "/api/v1/organizations/", 41 | task["organization_id"], 42 | "/locations/", 43 | task["location_id"], 44 | "/roles/", 45 | task["role_id"], 46 | "/users/", 47 | ) 48 | results = Requests.get(build_uri(config, path)) 49 | 50 | if results.status != 200 51 | code = results.status 52 | Logging.err("workers API down - code $code") 53 | error() 54 | end 55 | workers = JSON.parse(Requests.text(results))["data"] 56 | Logging.info( "workers Info Received") 57 | Logging.debug( "workers Info: $workers") 58 | for worker in workers 59 | if worker["name"] == nothing 60 | worker["name"] = worker["email"] 61 | end 62 | end 63 | return workers 64 | end 65 | 66 | function fetch_availability(task) 67 | path = string( 68 | "/api/v1/organizations/", 69 | task["organization_id"], 70 | "/locations/", 71 | task["location_id"], 72 | "/roles/", 73 | task["role_id"], 74 | "/schedules/", 75 | task["schedule_id"], 76 | "/availabilities/", 77 | ) 78 | results = Requests.get(build_uri(config, path)) 79 | 80 | if results.status != 200 81 | code = results.status 82 | Logging.err( "availabilities API down - code $code") 83 | error() 84 | end 85 | availabilities = JSON.parse(Requests.text(results)) 86 | Logging.info( "availabilities Info Received") 87 | return availabilities["data"] 88 | end 89 | -------------------------------------------------------------------------------- /StaffJoy/unit_tests/week_test.jl: -------------------------------------------------------------------------------- 1 | function week_test_unit() 2 | test_shift_count_validation() 3 | test_consecutive() 4 | test_consecutive_with_preceding() 5 | test_week_with_day_off() 6 | test_courier_incorrect_availability() 7 | end 8 | 9 | function test_shift_count_validation() 10 | # Shift counts have to be reasonable 11 | 12 | # Setup 13 | env = { 14 | "coverage" => Array[ 15 | [1*ones(Int, 8)], 16 | [1*ones(Int, 8)], 17 | [zeros(Int, 8)], 18 | ], 19 | "time_between_coverage" => 12, 20 | } 21 | 22 | # Shift count infeasible 23 | employees = { 24 | "a" => { 25 | "hours_min" => 14, 26 | "hours_max" => 18, 27 | "shift_count_min" => 2, 28 | "shift_count_max" => 2, 29 | "availability" => Array[ 30 | [ones(Int,8)], 31 | [ones(Int,8)], 32 | [zeros(Int,8)], 33 | ], 34 | "longest_availability" => 8*ones(Int, 8), 35 | }, 36 | } 37 | 38 | # (don't overwrite our good variables) 39 | ok, message, _ = build_week(employees, env) 40 | # It should require shift_time_min and shift_time_max 41 | @test(!ok) 42 | 43 | env["shift_time_min"] = 7 44 | env["shift_time_max"] = 9 45 | ok, employees, env = build_week(employees, env) 46 | @test(ok) 47 | ok, message = validate_input(employees, env) 48 | @test(ok) 49 | 50 | # shift_count_min has to be in bounds 51 | employees["a"]["shift_count_min"] = 3 52 | ok, message = validate_input(employees, env) 53 | @test(!ok) 54 | 55 | # shift_count_max has to be shorter than week 56 | employees["a"]["shift_count_min"] = 2 # revert 57 | employees["a"]["shift_count_max"] = 4 58 | ok, message = validate_input(employees, env) 59 | @test(!ok) 60 | 61 | # Test hours constraint 62 | employees["a"]["shift_count_max"] = 3 63 | ok, message = validate_input(employees, env) 64 | @test(!ok) 65 | end 66 | 67 | function test_consecutive() 68 | # Make sure consecutive shifts work 69 | ok, _ = assign_employees_to_days(test_consecutive_employees, test_consecutive_env, true) 70 | @test ok == true 71 | end 72 | 73 | function test_consecutive_with_preceding() 74 | # Make sure consecutive_with_preceding shifts work 75 | ok, employees = assign_employees_to_days(test_consecutive_with_preceding_employees, test_consecutive_with_preceding_env, true) 76 | @test ok == true 77 | # It's set up so "e" muet work these days 78 | @test employees["e"]["days_assigned"][1] == 0 79 | @test employees["e"]["days_assigned"][2] == 1 80 | @test employees["e"]["days_assigned"][3] == 1 81 | @test employees["e"]["days_assigned"][4] == 0 82 | end 83 | 84 | function test_week_with_day_off() 85 | consec_days_off = false # by design 86 | empty_day = 3 # when there is zero coverage 87 | ok, employees = assign_employees_to_days(test_day_off_employees, test_day_off_env, consec_days_off) 88 | @test ok == true 89 | for e in keys(employees) 90 | @test employees[e]["days_assigned"][empty_day] == 0 91 | end 92 | end 93 | -------------------------------------------------------------------------------- /StaffJoy/unit_tests/unassigned_test.jl: -------------------------------------------------------------------------------- 1 | function unassigned_test_unit() 2 | test_is_unassigned_shift_success() 3 | test_is_unassigned_shift_failure() 4 | test_meet_base_coverage_sufficient() 5 | test_meet_base_coverage_generate() 6 | test_meet_base_coverage_empty_employees() 7 | test_meet_unassigned_base_coverage() 8 | end 9 | 10 | function test_is_unassigned_shift_success() 11 | # Run it a few times - it generates randomly 12 | for i in 1:20 13 | name, employee = generate_unassigned_shift({ 14 | "shift_time_min" => 4, 15 | "shift_time_max" => 8, 16 | "coverage" => Array[[2,3,4,],[1,2,3],[1,0,0]], 17 | }) 18 | 19 | @test is_unassigned_shift(name) 20 | end 21 | end 22 | 23 | function test_is_unassigned_shift_failure() 24 | not_unassigned_shifts = Any[ 25 | "Lenny", 26 | "Lenny Euler", 27 | "1", 28 | 1, 29 | "StaffJoy.com rocks", 30 | "-1", 31 | ] 32 | 33 | for s in not_unassigned_shifts 34 | @test !is_unassigned_shift(s) 35 | end 36 | end 37 | 38 | function test_meet_base_coverage_sufficient() 39 | # Enough coverage - don't need to generate any more shifts 40 | # We'll use tests we know pass in functional testing 41 | new_employees = meet_base_coverage(test_bike_week_1_env, test_bike_week_1_employees) 42 | 43 | 44 | @test length(new_employees) == length(test_bike_week_1_employees) 45 | end 46 | 47 | function test_meet_base_coverage_generate() 48 | employees = { 49 | "Lenny" => { 50 | "hours_min" => 18, 51 | "hours_max" => 32, 52 | "shift_count_max" => 5, 53 | "availability" => Array[ 54 | [ones(Int, 12)], 55 | [ones(Int, 12)], 56 | [ones(Int, 12)], 57 | [ones(Int, 12)], 58 | [ones(Int, 12)], 59 | [zeros(Int, 4), ones(Int, 8)], 60 | [ones(Int, 12)], 61 | ], 62 | }, 63 | } 64 | new_employees = meet_base_coverage(test_bike_week_1_env, employees) 65 | @test length(new_employees) > length(employees) 66 | for e in keys(new_employees) 67 | if !(e in keys(employees)) 68 | @test is_unassigned_shift(e) 69 | end 70 | end 71 | end 72 | 73 | function test_meet_base_coverage_empty_employees() 74 | # Test again with null employees 75 | employees = Dict() 76 | new_employees = meet_base_coverage(test_bike_week_1_env, employees) 77 | @test length(new_employees) > 0 78 | for e in keys(new_employees) 79 | @test is_unassigned_shift(e) 80 | end 81 | end 82 | 83 | function test_meet_unassigned_base_coverage() 84 | env = { 85 | "coverage" => Array[ 86 | [1, 1, 1, 1, 1], 87 | [2, 2, 2, 2, 2], 88 | [3, 3, 3, 3, 3], 89 | ], 90 | "shift_time_min" => 1, 91 | "shift_time_max" => 3, 92 | } 93 | day = 2 94 | expected_max = 10 95 | expected_min = 4 96 | 97 | employees = meet_unassigned_base_coverage(env, day) 98 | shift_count = length(employees) 99 | @test shift_count > expected_min 100 | @test shift_count < expected_max 101 | for name in keys(employees) 102 | @test is_unassigned_shift(name) 103 | end 104 | end 105 | -------------------------------------------------------------------------------- /StaffJoy/unit_tests/windowing_test.jl: -------------------------------------------------------------------------------- 1 | function windowing_test_unit() 2 | test_windowing_same_day_length() 3 | test_windowing_different_day_length() 4 | test_windowing_zero_day() 5 | test_windowing_noop() 6 | end 7 | 8 | function test_windowing_same_day_length() 9 | # Pulled from courier 60 min data (Actual bike data aug 3 2015) 10 | input = Array[ 11 | [0, 0, 0, 0, 2, 3, 4, 4, 4, 5, 4, 4, 4, 4, 3, 2, 1, 0, 0, 0, 0, 0, 0, 0], 12 | [0, 0, 0, 0, 2, 3, 4, 4, 4, 5, 4, 4, 4, 4, 3, 2, 1, 0, 0, 0, 0, 0, 0, 0], 13 | [0, 0, 0, 0, 2, 3, 4, 4, 4, 5, 4, 4, 4, 4, 3, 2, 1, 0, 0, 0, 0, 0, 0, 0], 14 | [0, 0, 0, 0, 2, 3, 4, 4, 4, 5, 4, 4, 4, 4, 3, 2, 1, 0, 0, 0, 0, 0, 0, 0], 15 | [0, 0, 0, 0, 2, 3, 4, 4, 4, 5, 4, 4, 4, 4, 3, 2, 1, 0, 0, 0, 0, 0, 0, 0], 16 | [0, 0, 0, 0, 1, 1, 1, 2, 2, 2, 2, 2, 2, 2, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0], 17 | [0, 0, 0, 0, 1, 1, 1, 2, 2, 2, 2, 2, 2, 2, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0] 18 | ] 19 | # (start_index, end_index, time_delta) 20 | expected = (5, 17, 11) 21 | 22 | actual = get_window(input) 23 | @test actual == expected 24 | end 25 | 26 | function test_windowing_different_day_length() 27 | # Pulled from courier 60 min data (Actual bike data aug 3 2015) 28 | input = Array[ 29 | [0, 0, 0, 2, 2, 3, 4, 4, 4, 5, 4, 4, 4, 4, 3, 2, 1, 0, 0, 0, 0, 0, 0, 0], 30 | [0, 0, 0, 0, 0, 3, 4, 4, 4, 5, 4, 4, 4, 4, 3, 2, 1, 0, 0, 0, 0, 0, 0, 0], 31 | [0, 0, 0, 0, 2, 3, 4, 4, 0, 5, 4, 4, 4, 4, 3, 2, 1, 0, 0, 0, 0, 0, 0, 0], 32 | [0, 0, 0, 0, 2, 3, 4, 4, 4, 5, 4, 4, 4, 4, 3, 2, 1, 0, 0, 0, 0, 0, 0, 0], 33 | [0, 0, 0, 0, 2, 3, 4, 4, 4, 5, 4, 4, 4, 4, 3, 2, 1, 0, 0, 0, 0, 0, 0, 0], 34 | [0, 0, 0, 0, 1, 1, 1, 2, 2, 2, 2, 2, 2, 2, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0], 35 | [0, 0, 0, 0, 1, 1, 1, 2, 2, 2, 2, 2, 2, 2, 1, 1, 1, 0, 1, 0, 0, 0, 0, 0] 36 | ] 37 | # (start_index, end_index, time_delta) 38 | expected = (4, 19, 8) 39 | 40 | actual = get_window(input) 41 | @test actual == expected 42 | end 43 | 44 | function test_windowing_zero_day() 45 | input = Array[ 46 | [0, 0, 0, 0, 2, 3, 4, 4, 4, 5, 4, 4, 4, 4, 3, 2, 1, 0, 0, 0, 0, 0, 0, 0], 47 | [0, 0, 0, 0, 2, 3, 4, 4, 4, 5, 4, 4, 4, 4, 3, 2, 1, 0, 0, 0, 0, 0, 0, 0], 48 | [0, 0, 0, 0, 2, 3, 4, 4, 4, 5, 4, 4, 4, 4, 3, 2, 1, 0, 0, 0, 0, 0, 0, 0], 49 | [0, 0, 0, 0, 2, 3, 4, 4, 4, 5, 4, 4, 4, 4, 3, 2, 1, 0, 0, 0, 0, 0, 0, 0], 50 | [0, 0, 0, 0, 2, 3, 4, 4, 4, 5, 4, 4, 4, 4, 3, 2, 1, 0, 0, 0, 0, 0, 0, 0], 51 | # Zero day 52 | [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], 53 | [0, 0, 0, 0, 1, 1, 1, 2, 2, 2, 2, 2, 2, 2, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0] 54 | ] 55 | 56 | # (start_index, end_index, time_delta) 57 | expected = (5, 17, 11) 58 | 59 | actual = get_window(input) 60 | @test actual == expected 61 | end 62 | 63 | function test_windowing_noop() 64 | input = Array[ 65 | [2, 3, 4, 4, 4, 5, 4, 4, 4, 4, 3, 2, 1], 66 | [2, 3, 4, 4, 4, 5, 4, 4, 4, 4, 3, 2, 1], 67 | [2, 3, 4, 4, 4, 5, 4, 4, 4, 4, 3, 2, 1], 68 | [2, 3, 4, 4, 4, 5, 4, 4, 4, 4, 3, 2, 1], 69 | [2, 3, 4, 4, 4, 5, 4, 4, 4, 4, 3, 2, 1], 70 | [1, 1, 1, 2, 2, 2, 2, 2, 2, 2, 1, 1, 1], 71 | [1, 1, 1, 2, 2, 2, 0, 2, 2, 2, 1, 1, 0] 72 | ] 73 | 74 | expected = (1, 13, 0) 75 | 76 | actual = get_window(input) 77 | @test actual == expected 78 | end 79 | -------------------------------------------------------------------------------- /StaffJoy/unassigned.jl: -------------------------------------------------------------------------------- 1 | # 2 | # Functions for handling unassigned shifts 3 | # 4 | 5 | # Use this when there are not enough employees (or no employees) 6 | # so we don't have 80 loops of unassigned shifts 7 | function meet_base_coverage(env, employees) 8 | employees = deepcopy(employees) 9 | days_per_week = size(env["coverage"], 1) 10 | sum_coverage = sum(sum(env["coverage"])) 11 | shift_count = 0 12 | 13 | # Sum the hours of employees 14 | employee_hour_sum = 0 15 | for e in keys(employees) 16 | employee_hour_sum += employees[e]["hours_max"] 17 | end 18 | 19 | # min_lift actually raises the minimum number of hours needed. 20 | # It helps feasibility, but could hurt the one case of a perfect, single- 21 | # solution optimal schedule. 22 | while employee_hour_sum < (sum_coverage*MIN_LYFT) 23 | name, value = generate_unassigned_shift(env) 24 | employees[name] = value 25 | employee_hour_sum += value["hours_max"] 26 | shift_count += 1 27 | end 28 | 29 | Logging.info( "Generated $shift_count shifts during pre-processing") 30 | return employees 31 | end 32 | 33 | # Generate lots of unassigned shifts - more than we need - 34 | # so it is fast 35 | function meet_unassigned_base_coverage(env, day) 36 | sum_coverage = sum(env["coverage"][day]) 37 | 38 | average_unassigned_shift_length = env["shift_time_min"] + (env["shift_time_max"] - env["shift_time_min"]) * UNASSIGNED_RATIO 39 | unassigned_shift_count = ceil(sum_coverage / average_unassigned_shift_length) 40 | 41 | # Meet peak staffing level 42 | peak = 0 43 | for t in 1:length(env["coverage"][day]) 44 | if env["coverage"][day][t] > peak 45 | peak = env["coverage"][day][t] 46 | end 47 | end 48 | if peak > unassigned_shift_count 49 | Logging.info("Peak coverage ($peak shifts) chosen instead for unassigned shift count ($unassigned_shift_count shifts) as starting point") 50 | unassigned_shift_count = peak 51 | end 52 | 53 | employees = Dict() 54 | for i in 1:unassigned_shift_count 55 | name, value = generate_unassigned_shift(env) 56 | employees[name] = value 57 | end 58 | 59 | 60 | Logging.info( "Generated $unassigned_shift_count unassigned shifts during pre-processing for day $day") 61 | return employees 62 | end 63 | 64 | function generate_unassigned_shift(env) 65 | shift_time_min = env["shift_time_min"] 66 | shift_time_max = env["shift_time_max"] 67 | shift_count = 1 # Each unassigned shift is a single shift 68 | 69 | # need unique names 70 | name = string(UNASSIGNED_PREFIX, randstring(8)) 71 | 72 | # Available all the time 73 | availability = Array[] 74 | days_per_week = size(env["coverage"], 1) 75 | for day in 1:days_per_week 76 | push!(availability, [ones(Int, length(env["coverage"][day]))]) 77 | end 78 | 79 | # return tuple so you can do employees[name] = values 80 | return name, { 81 | "hours_min" => shift_time_min, 82 | "shift_time_min" => shift_time_min, 83 | "hours_max" => shift_time_max, 84 | "shift_time_max" => shift_time_max, 85 | "shift_count_max" => shift_count, 86 | "shift_count_min" => shift_count, 87 | "availability" => availability, 88 | } 89 | end 90 | 91 | function is_unassigned_shift(name) 92 | # Julia doesn't wrap bounds :-( 93 | if length(UNASSIGNED_PREFIX) > length(name) 94 | return false 95 | end 96 | return name[1:length(UNASSIGNED_PREFIX)] == UNASSIGNED_PREFIX 97 | end 98 | -------------------------------------------------------------------------------- /StaffJoy.jl: -------------------------------------------------------------------------------- 1 | module StaffJoy 2 | 3 | 4 | # module dependencies 5 | importall JuMP 6 | using Base.Test: @test 7 | importall JSON 8 | import Logging 9 | 10 | # Functions available elsewhere 11 | export schedule, unit_test, functional_test, is_unassigned_shift, anneal_availability 12 | 13 | if "ENV" in keys(ENV) && ( 14 | ENV["ENV"] == "stage" || ENV["ENV"] == "prod" 15 | ) 16 | 17 | env = ENV["ENV"] 18 | Logging.configure(level=Logging.INFO) 19 | else 20 | env = "dev" 21 | Logging.configure(level=Logging.DEBUG) 22 | end 23 | 24 | if "Gurobi" in keys(Pkg.installed()) 25 | Logging.info("Gurobi detected") 26 | using Gurobi 27 | GUROBI = true 28 | else 29 | GUROBI = false 30 | using Cbc 31 | Logging.info("Using CBC solver") 32 | end 33 | 34 | UNASSIGNED_PREFIX = "unassigned_shift_" # Just basically substring matching for when we generate unassigned shifts 35 | UNASSIGNED_ID = 0 # user id for sending back to scheduler 36 | 37 | # what's available elsewhere 38 | 39 | LYFT_THROTTLE = .999 40 | MIN_LYFT = 1.1 # If it's less than this, don't even try 41 | 42 | # 0 generates all fixed shifts at min_shift_length 43 | # 1 generates all fixed shifts at max_shift_length 44 | # lower means: more shifts, easier feasibility, lower theoretical optimality 45 | UNASSIGNED_RATIO = .4 46 | BIFURCATE_THRESHOLD = 350 # labor sum per week 47 | 48 | 49 | # Amount of time to search for at least one feasible solutions before adding unassigned 50 | if "ITERATION_TIMEOUT" in keys(ENV) 51 | ITERATION_TIMEOUT = env["ITERATION_TIMEOUT"] 52 | else 53 | ITERATION_TIMEOUT = 6 * 60 # 6 minutes 54 | end 55 | 56 | # Max amount of time to search for feasible solutions in a given lift 57 | # IF AND ONLY IF at least one existing solution has been found. 58 | # This is like "We have one solution, let's find lots for optimality" 59 | if "SEARCH_TIMEOUT" in keys(ENV) 60 | SEARCH_TIMEOUT = env["SEARCH_TIMEOUT"] 61 | else 62 | # Should be longer than ITERATION_TIMEOUT 63 | SEARCH_TIMEOUT = 20 * 60 # 20 minutes 64 | end 65 | 66 | # Longest time to run an individual calculation 67 | if "CALCULATION_TIMEOUT" in keys(ENV) 68 | CALCULATION_TIMEOUT = env["ITERATION_TIMEOUT"] 69 | else 70 | CALCULATION_TIMEOUT = 3 * 60 # 3 minutes 71 | end 72 | 73 | # 74 | # Includes 75 | # 76 | 77 | # Module 78 | include("StaffJoy/serial_schedulers.jl") 79 | include("StaffJoy/unassigned.jl") 80 | include("StaffJoy/week.jl") 81 | include("StaffJoy/day.jl") 82 | include("StaffJoy/helpers.jl") 83 | include("StaffJoy/windowing.jl") 84 | include("StaffJoy/bifurcate.jl") 85 | 86 | # Unit Tests 87 | include("StaffJoy/unit_tests/test.jl") 88 | include("StaffJoy/unit_tests/helpers_test.jl") 89 | include("StaffJoy/unit_tests/windowing_test.jl") 90 | include("StaffJoy/unit_tests/unassigned_test.jl") 91 | include("StaffJoy/unit_tests/week_test.jl") 92 | 93 | # Functional Tests 94 | TEST_THRESHOLD = 1.08 # Minimum efficiency 95 | include("StaffJoy/functional_tests/test.jl") 96 | include("StaffJoy/functional_tests/day_test.jl") 97 | include("StaffJoy/functional_tests/windowing_test.jl") 98 | include("StaffJoy/functional_tests/week_test.jl") 99 | include("StaffJoy/functional_tests/unassigned_test.jl") 100 | include("StaffJoy/functional_tests/bifurcate_test.jl") 101 | 102 | # Source data for tests 103 | include("StaffJoy/test_data/courier-bike-1.jl") 104 | include("StaffJoy/test_data/courier-car-1.jl") 105 | include("StaffJoy/test_data/courier-bike-monday.jl") 106 | include("StaffJoy/test_data/courier-incorrect-availability-length.jl") 107 | include("StaffJoy/test_data/day-prior-failure.jl") 108 | include("StaffJoy/test_data/consecutive-week.jl") 109 | include("StaffJoy/test_data/consecutive-week-with-preceding.jl") 110 | include("StaffJoy/test_data/week-with-day-off.jl") 111 | include("StaffJoy/test_data/delivery-unassigned.jl") 112 | 113 | end#module 114 | -------------------------------------------------------------------------------- /StaffJoy/helpers.jl: -------------------------------------------------------------------------------- 1 | function longest_consecutive(avail) 2 | # for [1 1 1 1 0 0 1 1] 3 | # return 4 4 | 5 | longest = 0 6 | current = 0 7 | 8 | for i in 1:length(avail) 9 | if avail[i] == 0 10 | current = 0 11 | else 12 | current+=1 13 | end 14 | 15 | if current > longest 16 | longest = current 17 | end 18 | end 19 | 20 | return longest 21 | end 22 | 23 | function anneal_availability(avail, min_length) 24 | # for [1 1 1 1 0 0 1 1] 25 | # if min_shift_length == 3 26 | # return [1 1 1 1 0 0 0 0] 27 | 28 | 29 | start_index = 0 30 | current_index = 0 31 | 32 | for i in 1:length(avail) 33 | if avail[i] == 1 && start_index == 0 34 | start_index = i 35 | current_index = i 36 | elseif ( 37 | (avail[i] == 0 && start_index > 0) 38 | || (avail[i] == 1 && i == length(avail)) 39 | ) # also note that this must come before other avail[i] == 1 40 | # if it's last element - manually correct end index 41 | if i == length(avail) 42 | current_index = i 43 | end 44 | 45 | patch_length = current_index - start_index + 1 46 | # plus 1 because inclusive->inclusive 47 | 48 | if patch_length < min_length 49 | for j in start_index:current_index 50 | avail[j] = 0 51 | end 52 | end 53 | start_index = 0 54 | current_index = 0 55 | elseif avail[i] == 1 && start_index > 0 56 | current_index += 1 57 | #elseif avail[i] == 0 && start_index == 0 58 | end 59 | end 60 | return avail 61 | end 62 | 63 | function get_day_bounds(longest_availability, days_assigned, hours_scheduled, week_min, week_max, shift_min) 64 | # note - longest_availability should only be for days remaining 65 | 66 | remaining_hours_max = week_max - hours_scheduled 67 | remaining_hours_min = week_min - hours_scheduled 68 | 69 | ## Day Min ## 70 | # every day but today that they're working 71 | future_capacity = sum(dot(longest_availability[2:end], days_assigned[2:end])) 72 | 73 | # Calculate day min based on future schedules 74 | day_min = remaining_hours_min - future_capacity 75 | 76 | # but ceiling it at shift_min from environment 77 | if day_min < shift_min 78 | day_min = shift_min 79 | end 80 | 81 | ## Day Max 82 | # Do not include today 83 | future_min_capacity = sum(days_assigned[2:end]) * shift_min 84 | day_max = remaining_hours_max - future_min_capacity 85 | 86 | # Floor at today's longest_availability 87 | if day_max > longest_availability[1] 88 | day_max = longest_availability[1] 89 | end 90 | 91 | return day_min, day_max 92 | end 93 | 94 | function get_longest_availability(employee, day) 95 | # find longest shift on that day 96 | longest = longest_consecutive(employee["availability"][day]) 97 | if longest < employee["shift_time_min"] 98 | # Effectively unable to work that day at all 99 | longest = 0 100 | end 101 | if longest > employee["shift_time_max"] 102 | # Effectively unable to work that day at all 103 | longest = employee["shift_time_max"] 104 | end 105 | 106 | return longest 107 | end 108 | 109 | function sub_array(array, start_index, end_index) 110 | # Difference: start index can be > end_index 111 | if start_index > end_index 112 | return [array[start_index:end], array[1:end_index]] 113 | end 114 | return array[start_index:end_index] 115 | end 116 | 117 | function days_available_per_week(employee) 118 | 119 | count = 0 120 | for t in 1:length(employee["availability"]) 121 | employee["availability"][t] = anneal_availability(employee["availability"][t], employee["shift_time_min"]) 122 | # Already annealed 123 | # Assume preprocessing sets availablility to 0 when coverage 0 124 | if sum(employee["availability"][t]) > 0 125 | count += 1 126 | end 127 | end 128 | return count 129 | end 130 | 131 | function day_sum_coverage(env) 132 | return sum(env["coverage"]) 133 | end 134 | 135 | function week_sum_coverage(env) 136 | return sum(sum(env["coverage"])) 137 | end 138 | 139 | function day_hours_scheduled(schedule) 140 | sum = 0 141 | for e in keys(schedule) 142 | sum += schedule[e]["length"] 143 | end 144 | return sum 145 | 146 | end 147 | 148 | function week_hours_scheduled(weekly_schedule) 149 | sum_hours = 0 150 | for e in keys(weekly_schedule) 151 | num_shifts = length(weekly_schedule[e]) 152 | for i in 1:num_shifts 153 | shift_length = weekly_schedule[e][i]["length"] 154 | sum_hours += shift_length 155 | end 156 | end 157 | return sum_hours 158 | end 159 | -------------------------------------------------------------------------------- /StaffJoy/test_data/courier-car-1.jl: -------------------------------------------------------------------------------- 1 | test_courier_car_1_env = { 2 | "shift_time_min" => 3, 3 | "shift_time_max" => 8, 4 | "coverage" => Array[ # 8AM->8PM 5 | # Monday 6 | [3, 3, 4, 4, 4, 4, 4, 4, 3, 3, 3, 3], 7 | # Tuesday 8 | [3, 3, 4, 4, 4, 4, 4, 4, 3, 3, 3, 3], 9 | # Wednesday 10 | [3, 3, 4, 4, 4, 4, 4, 4, 3, 3, 3, 3], 11 | # Thursday 12 | [3, 3, 4, 4, 4, 4, 4, 4, 3, 3, 3, 3], 13 | # Friday 14 | [3, 3, 4, 4, 4, 4, 4, 4, 3, 3, 3, 3], 15 | # Saturday 16 | [2, 3, 4, 4, 4, 4, 4, 4, 4, 4, 3, 3], 17 | # Sunday 18 | [2, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3], 19 | ], 20 | "time_between_coverage" => 12, 21 | "intershift" => 13, # Time periods between shifts 22 | } 23 | 24 | test_courier_car_1_employees = { 25 | "Aaron" => { 26 | "hours_min" => 18, 27 | "hours_max" => 32, 28 | "shift_count_max" => 5, 29 | "availability" => Array[ 30 | [ones(Int, 12)], 31 | [ones(Int, 12)], 32 | [ones(Int, 12)], 33 | [ones(Int, 12)], 34 | [ones(Int, 12)], 35 | [zeros(Int, 4), ones(Int, 8)], 36 | [ones(Int, 12)], 37 | ], 38 | }, 39 | "Austin" => { 40 | "hours_min" => 12, 41 | "hours_max" => 28, 42 | "shift_count_max" => 5, 43 | "availability" => Array[ 44 | [ones(Int, 12)], 45 | [zeros(Int, 12)], 46 | [ones(Int, 12)], 47 | [zeros(Int, 12)], 48 | [ones(Int, 12)], 49 | [ones(Int, 12)], 50 | [zeros(Int, 6), ones(Int, 6)], 51 | ], 52 | }, 53 | "Elliott" => { 54 | "hours_min" => 18, 55 | "hours_max" => 32, 56 | "shift_count_max" => 5, 57 | "availability" => Array[ 58 | [ones(Int, 12)], 59 | [ones(Int, 12)], 60 | [ones(Int, 12)], 61 | [ones(Int, 12)], 62 | [ones(Int, 12)], 63 | [ones(Int, 12)], 64 | [ones(Int, 12)], 65 | ], 66 | }, 67 | "Greg" => { 68 | "hours_min" => 18, 69 | "hours_max" => 32, 70 | "shift_count_max" => 5, 71 | "availability" => Array[ 72 | [ones(Int, 12)], 73 | [ones(Int, 12)], 74 | [ones(Int, 12)], 75 | [ones(Int, 12)], 76 | [ones(Int, 12)], 77 | [ones(Int, 12)], 78 | [ones(Int, 12)], 79 | ], 80 | }, 81 | "Kimberly" => { 82 | "hours_min" => 24, 83 | "hours_max" => 32, 84 | "shift_count_max" => 5, 85 | "availability" => Array[ 86 | [ones(Int, 12)], 87 | [ones(Int, 12)], 88 | [ones(Int, 7), zeros(Int, 4), 1], 89 | [ones(Int, 7), zeros(Int, 4), 1], 90 | [ones(Int, 12)], 91 | [ones(Int, 12)], 92 | [ones(Int, 12)], 93 | ], 94 | }, 95 | "Lewis" => { 96 | "hours_min" => 24, 97 | "hours_max" => 32, 98 | "shift_count_max" => 5, 99 | "availability" => Array[ 100 | [ones(Int, 12)], 101 | [ones(Int, 12)], 102 | [ones(Int, 12)], 103 | [ones(Int, 12)], 104 | [ones(Int, 12)], 105 | [ones(Int, 12)], 106 | [ones(Int, 12)], 107 | ], 108 | }, 109 | "Matthew" => { 110 | "hours_min" => 18, 111 | "hours_max" => 32, 112 | "shift_count_max" => 5, 113 | "availability" => Array[ 114 | [zeros(Int, 12)], 115 | [ones(Int, 12)], 116 | [ones(Int, 12)], 117 | [ones(Int, 12)], 118 | [ones(Int, 12)], 119 | [ones(Int, 12)], 120 | [ones(Int, 12)], 121 | ], 122 | }, 123 | "MerryJo" => { 124 | "hours_min" => 6, 125 | "hours_max" => 18, 126 | "shift_count_max" => 3, 127 | "availability" => Array[ 128 | [zeros(Int, 12)], 129 | [zeros(Int, 7), ones(Int, 5)], 130 | [zeros(Int, 7), ones(Int, 5)], 131 | [zeros(Int, 7), ones(Int, 5)], 132 | [zeros(Int, 12)], 133 | [zeros(Int, 12)], 134 | [zeros(Int, 12)], 135 | ], 136 | 137 | }, 138 | "Penelope" => { 139 | "hours_min" => 12, 140 | "hours_max" => 32, 141 | "shift_count_max" => 5, 142 | "availability" => Array[ 143 | [ones(Int, 12)], 144 | [ones(Int, 12)], 145 | [ones(Int, 12)], 146 | [ones(Int, 12)], 147 | [zeros(Int, 12)], 148 | [zeros(Int, 12)], 149 | [zeros(Int, 12)], 150 | ], 151 | }, 152 | "Robert" => { 153 | "hours_min" => 18, 154 | "hours_max" => 32, 155 | "shift_count_max" => 5, 156 | "availability" => Array[ 157 | [ones(Int, 12)], 158 | [zeros(Int, 12)], 159 | [ones(Int, 12)], 160 | [ones(Int, 12)], 161 | [ones(Int, 10), zeros(Int, 2)], 162 | [ones(Int, 8), zeros(Int, 4)], 163 | [zeros(Int, 12)], 164 | ], 165 | 166 | 167 | }, 168 | "Scott" => { 169 | "hours_min" => 12, 170 | "hours_max" => 32, 171 | "shift_count_max" => 5, 172 | "availability" => Array[ 173 | [zeros(Int, 12)], 174 | [ones(Int, 12)], 175 | [ones(Int, 12)], 176 | [ones(Int, 12)], 177 | [ones(Int, 12)], 178 | [ones(Int, 12)], 179 | [zeros(Int, 12)], 180 | ], 181 | }, 182 | } 183 | -------------------------------------------------------------------------------- /StaffJoy/test_data/courier_overscheduled.json: -------------------------------------------------------------------------------- 1 | {"David": {"hours_max": 30, "shift_time_min": 4, "availability": [[0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1], [0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1], [0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1], [0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1], [0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1], [0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1], [0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1]], "shift_count_min": 3, "intershift": 0, "shift_count_max": 5, "shift_time_max": 6, "hours_min": 12}, "Juanita": {"hours_max": 40, "shift_time_min": 4, "availability": [[1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0], [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1], [1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0], [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1], [1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0], [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]], "shift_count_min": 5, "intershift": 0, "shift_count_max": 5, "shift_time_max": 8, "hours_min": 20}, "Q": {"hours_max": 60, "shift_time_min": 4, "availability": [[1, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1], [1, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1], [1, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1], [1, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1], [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1], [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1], [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1]], "shift_count_min": 4, "intershift": 0, "shift_count_max": 7, "shift_time_max": 8, "hours_min": 16}, "Yu": {"hours_max": 25, "shift_time_min": 5, "availability": [[1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0], [1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0], [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], [1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0], [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]], "shift_count_min": 3, "intershift": 0, "shift_count_max": 5, "shift_time_max": 6, "hours_min": 15}, "Josh": {"hours_max": 40, "shift_time_min": 5, "availability": [[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], [0, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0], [0, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0], [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], [0, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0], [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]], "shift_count_min": 3, "intershift": 0, "shift_count_max": 5, "shift_time_max": 8, "hours_min": 15}, "Doug": {"hours_max": 30, "shift_time_min": 4, "availability": [[0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1], [0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1], [0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1], [0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1], [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1]], "shift_count_min": 2, "intershift": 0, "shift_count_max": 5, "shift_time_max": 6, "hours_min": 8}, "Shaun": {"hours_max": 18, "shift_time_min": 4, "availability": [[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]], "shift_count_min": 0, "intershift": 0, "shift_count_max": 4, "shift_time_max": 6, "hours_min": 0}, "Douglas": {"hours_max": 50, "shift_time_min": 5, "availability": [[1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1], [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1], [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1], [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1], [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1], [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1], [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1]], "shift_count_min": 4, "intershift": 0, "shift_count_max": 7, "shift_time_max": 8, "hours_min": 20}, "Steven": {"hours_max": 40, "shift_time_min": 4, "availability": [[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1], [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1]], "shift_count_min": 0, "intershift": 0, "shift_count_max": 5, "shift_time_max": 6, "hours_min": 0}, "David Lucey": {"hours_max": 18, "shift_time_min": 4, "availability": [[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], [0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0], [0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0], [0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0], [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]], "shift_count_min": 0, "intershift": 0, "shift_count_max": 3, "shift_time_max": 6, "hours_min": 0}, "Stefen Smith": {"hours_max": 30, "shift_time_min": 4, "availability": [[1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1], [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1], [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1], [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1], [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1], [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1], [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1]], "shift_count_min": 2, "intershift": 0, "shift_count_max": 5, "shift_time_max": 6, "hours_min": 8}, "Cameron Smith": {"hours_max": 25, "shift_time_min": 4, "availability": [[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1], [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1], [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1], [1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0], [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], [0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1]], "shift_count_min": 3, "intershift": 0, "shift_count_max": 5, "shift_time_max": 6, "hours_min": 12}, "Patrick Glynn": {"hours_max": 40, "shift_time_min": 4, "availability": [[1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1], [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0], [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1], [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1], [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1], [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1], [0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1]], "shift_count_min": 0, "intershift": 0, "shift_count_max": 5, "shift_time_max": 8, "hours_min": 0}, "Ch": {"hours_max": 25, "shift_time_min": 4, "availability": [[1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0], [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0], [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0], [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0], [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0], [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]], "shift_count_min": 1, "intershift": 0, "shift_count_max": 5, "shift_time_max": 6, "hours_min": 4}} -------------------------------------------------------------------------------- /Manager/filters.jl: -------------------------------------------------------------------------------- 1 | # Functions for filtering availability and hours per week. 2 | 3 | function fill_missing_availability(schedule, api_availability, workers, organization, task, send_messages=true) 4 | #= 5 | This function merges availability into users, and if availability is 6 | not set (eg the user forgot to set it) - then it marks them as availabile all the time. 7 | =# 8 | w_out = Any[] 9 | 10 | for worker in workers 11 | if worker["max_shifts_per_week"] == 0 12 | # No need to send message - this is is expected behavior 13 | id = worker["id"] 14 | Logging.info( "Worker id $id has max_shifts 0 - removed") 15 | continue 16 | end 17 | 18 | w_availability = nothing 19 | for a in api_availability 20 | if a["user_id"] == worker["id"] 21 | w_availability = a["availability"] 22 | end 23 | end 24 | if w_availability == nothing 25 | w_availability = Dict() 26 | for day in days(organization) 27 | w_availability[day] = ones(Int, length(schedule["demand"][day])) 28 | end 29 | name = worker["name"] 30 | if send_messages 31 | send_message("$name did not set availability", task) 32 | end 33 | end 34 | worker["availability"] = w_availability 35 | push!(w_out, worker) 36 | end 37 | return w_out 38 | end 39 | 40 | function downgrade_availability(schedule, workers, organization, task, send_messages=true) 41 | w_out = Any[] 42 | 43 | for worker in workers 44 | # availability and max hours per shift 45 | max_hours = Dict() # key is day 46 | week_hour_sum = 0 47 | days_available = 0 48 | name = worker["name"] 49 | # Max hours per week depends on hours per day, which depends on 50 | for day in days(organization) 51 | # If business is closed, mark availability as 0 52 | for t in 1:length(schedule["demand"][day]) 53 | if schedule["demand"][day][t] == 0 54 | worker["availability"][day][t] = 0 55 | end 56 | end 57 | 58 | day_avail = StaffJoy.anneal_availability(worker["availability"][day], worker["min_shift_length"]) 59 | 60 | # This is for app->staffjoy conversion 61 | worker["shift_time_max"] = worker["max_shift_length"] 62 | worker["shift_time_min"] = worker["min_shift_length"] 63 | day_longest = StaffJoy.get_longest_availability(worker, day) 64 | # Gotta clean up for unit tests :-( 65 | delete!(worker, "shift_time_max") 66 | delete!(worker, "shift_time_min") 67 | max_hours[day] = day_longest 68 | week_hour_sum += day_longest 69 | if day_longest > 0 70 | days_available += 1 71 | end 72 | end 73 | 74 | completely_unavailable = false 75 | adjust_shifts = false 76 | adjust_hours = false 77 | 78 | # Check if completely unavailable 79 | if week_hour_sum == 0 80 | Logging.info( "$name completely unavailable") 81 | if send_messages 82 | send_message("$name is completely unavailable to work this week, so they were not scheduled", task) 83 | end 84 | continue 85 | end 86 | 87 | # First round - downgrade total hours for the week 88 | if week_hour_sum < worker["max_hours_per_week"] 89 | adjust_hours = true 90 | worker["max_hours_per_week"] = week_hour_sum 91 | end 92 | 93 | if week_hour_sum < worker["min_hours_per_week"] 94 | adjust_hours = true 95 | worker["min_hours_per_week"] = week_hour_sum 96 | end 97 | 98 | 99 | # Second round - check shift count 100 | if days_available < worker["max_shifts_per_week"] 101 | adjust_shifts = true 102 | worker["max_shifts_per_week"] = days_available 103 | end 104 | 105 | if days_available < worker["min_shifts_per_week"] 106 | adjust_shifts = true 107 | worker["min_shifts_per_week"] = days_available 108 | end 109 | 110 | if worker["min_shifts_per_week"] > worker["max_shifts_per_week"] 111 | adjust_shifts = true 112 | worker["min_shifts_per_week"] = worker["max_shifts_per_week"] 113 | end 114 | 115 | # Third - see that the shifts * hour bands are within hours per week 116 | while worker["max_hours_per_week"] < worker["min_shifts_per_week"] * worker["min_shift_length"] 117 | adjust_shifts = true 118 | worker["min_shifts_per_week"] -= 1 119 | end 120 | 121 | if worker["max_hours_per_week"] > worker["max_shifts_per_week"] * worker["max_shift_length"] 122 | adjust_shifts = true 123 | worker["max_hours_per_week"] = worker["max_shifts_per_week"] * worker["max_shift_length"] 124 | end 125 | 126 | if worker["min_hours_per_week"] < worker["min_shifts_per_week"] * worker["min_shift_length"] 127 | adjust_shifts = true 128 | worker["min_hours_per_week"] = worker["min_shifts_per_week"] * worker["min_shift_length"] 129 | end 130 | 131 | if worker["min_shifts_per_week"] > worker["max_shifts_per_week"] 132 | adjust_shifts = true 133 | worker["min_shifts_per_week"] = worker["max_shifts_per_week"] 134 | end 135 | if worker["min_hours_per_week"] > worker["max_hours_per_week"] 136 | adjust_hours = true 137 | worker["min_hours_per_week"] = worker["max_hours_per_week"] 138 | end 139 | 140 | while worker["min_shifts_per_week"]*worker["max_shift_length"] < worker["min_hours_per_week"] 141 | # This isn't really a messageable change 142 | worker["min_shifts_per_week"] += 1 143 | end 144 | 145 | 146 | # Message time 147 | push!(w_out, worker) 148 | 149 | if adjust_shifts && adjust_hours 150 | 151 | Logging.info( "$name hours and shifts downgraded") 152 | if send_messages 153 | send_message("$name is not available to work the set shifts and hours this week, so we decreased them to match their availability.", task) 154 | end 155 | 156 | elseif adjust_shifts 157 | 158 | Logging.info( "$name shifts downgraded") 159 | if send_messages 160 | send_message("$name is not available to work the specified number of shifts this week, so we decreased them to match their availability.", task) 161 | end 162 | 163 | elseif adjust_hours 164 | Logging.info( "$name hours downgraded") 165 | if send_messages 166 | send_message("$name is not available to work the specified number of hours this week, so we decreased them to match their availability.", task) 167 | end 168 | end 169 | end 170 | return w_out 171 | end 172 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Staffjoy Autoscheduler 2 | 3 | [![Build Status](https://travis-ci.org/Staffjoy/autoscheduler.svg?branch=master)](https://travis-ci.org/Staffjoy/autoscheduler) [![Moonlight](https://img.shields.io/badge/contractors-1-brightgreen.svg)](https://moonlightwork.com/staffjoy) 4 | 5 | [Staffjoy is shutting down](https://blog.staffjoy.com/staffjoy-is-shutting-down-39f7b5d66ef6#.ldsdqb1kp), so we are open-sourcing our code. This repo was the original scheduling algorithm. The repo predates Staffjoy as a company by about a year, and it underwent constant rewrites and improvements. Along the way, we mixed integer programming libraries with techniques like dynamic programming. We used the [Julia Programming Language](http://julialang.org/), and Staffjoy is eternally grateful to the [Julia JuMP project](https://github.com/JuliaOpt/JuMP.jl) for making such a great library that it rekindled an interested in scheduling optimization algorithms that became Staffjoy. 6 | 7 | This scheduler started serving paying customers via spreadsheets over email starting in January 2015, then eventually integrated with a web application that we launched at the end of that year. It served production traffic until it was replaced by [Chomp decomposition](https://github.com/chomp-decomposition) and [Mobius assignment](https://blog.staffjoy.com/introducing-mobius-giving-employees-the-shifts-they-want-5eadfdf6de71) in 2016. 8 | 9 | In production, we used a proprietary third party solving package that provided significant speed gains. If that library is not present, the library falls back to a slower, open-source library. As such, some tests must be disabled for the public CI system. 10 | 11 | ## Credit 12 | 13 | The architect primary author of this repo was [@philipithomas](https://github.com/philipithomas). This is a fork of the internal repository. 14 | 15 | ## General Approach 16 | 17 | This approach uses a variety of dynamic programming techniques and heuristics to create workers then assign them to shifts. It assumes that businesses are not open 24/7 (or, if they are, that all shifts turn over at one particular time.) It has two basic optimization models: 18 | 19 | 1. **Week Model** - Assigns workers to days of the week to work, while maximizing `lift` - basically, a ratio of (worker availability)/(hours of work to do). A ratio of less than one is impossible to solve. We found that, the higher the ratio, the more likely a day model is to converge. 20 | 2. **Day Model** - Given workers, and demand for a day, it generates assigned shifts, each with a start and end time, while minimizing the total number of hour scheduled. 21 | 22 | It combines these two models with **Serial Scheduling**, where we start with one day of the week, run the Day Model, then schedule the following day - with its model being modified for how the previous day's schedule affects the following day, and continue this until the end of the week. 23 | 24 | If a worker work slate on Monday, they can't open on Tuesday. If they have a limit of 40 hours per week and are scheduled for 34 hours, they cannot work more than 6 hours on any remaining day. Due to these changes, the model must be run serially. 25 | 26 | In practice, we start the serial scheduling on each of the seven days of the week, and run the seven different serial scheduling models in parallel. Every Day Model is cached, so in practice - seeding the serial scheduler with all start days only adds additional computation time if the results change. 27 | 28 | Overall, the algorithm basically functions like this: 29 | 30 | 1. Minimize `lift` in the Week Model 31 | 2. Run Serial Scheduling, starting with each day of the week. If we hit perfect optimality, return. Otherwise, keep the best solution. 32 | 3. Re-run the Week Model, where the `lift` must be lower than the previous one, and repeat until we time out. 33 | 34 | ## Dev 35 | 36 | Run `vagrant up` to build the development environment. Enter it with `vagrant ssh`. Code is synced to the `/vagrant` folder. You can run tests and the server inside this development environment. 37 | 38 | To rebuild the development environment, run `vagrant destroy -f && vagrant up` *outside* the VM. 39 | 40 | ## Tests 41 | 42 | * `make test`: runs unit tests and functional test for both the StaffJoy module and the Manager module. (`make scheduler-test && make manager-test`) 43 | * `make test-unit`: runs unit tests for the StaffJoy module and the Manager module. Skips the (very slow, resource-intensive) functional tests. (`make scheduler-test-light && make manager-test`) 44 | * `make test-functional`: runs unit tests for the StaffJoy module and the Manager module. 45 | * `make test-scheduler-unit`: runs unit tests for the StaffJoy module 46 | * `make test-manager-unit`: runs unit tests for the Manager module 47 | * `make test-scheduler-functional`: runs functional tests for the StaffJoy module. Very slow - probably requires four cores and 45 min to pass. 48 | * `make test-manager-functional`: runs unit tests for the Manager module. 49 | 50 | ## Modules 51 | 52 | ### StaffJoy Module 53 | 54 | The StaffJoy module includes the core logic for the day scheduler and the week scheduler. Consider this application "Dumb" - it should not be aware of environment, continuous time, task queue, etc. 55 | 56 | ### Manager Module 57 | 58 | The Manager module translates requests from the App for calculation using the StaffJoy module. This includes: 59 | 60 | * Environment (dev / stage / prod) awareness 61 | * Tasking - retrieving data from the App server 62 | * Server - set up a service that runs on a server, and perhaps in a Docker container. 63 | * Response - send back completed schedules to the App 64 | 65 | ## Request format 66 | 67 | This is what the JSON for the Manager should look like when it does a POST request to the API: 68 | 69 | ``` 70 | POST 71 | { 72 | "schedule_id": 1, 73 | "role_id": 1, 74 | "location_id": 1, 75 | "organization_id": 1, 76 | "api_token": "abc123" 77 | } 78 | ``` 79 | 80 | ## Org Options 81 | 82 | These org options are consumed by scheduler Others are disregarded. 83 | 84 | ``` 85 | { 86 | data: { 87 | "no_shifts_after": 17, // Optional - stop shifts after time 17 (exclusive) only in pure unassigned shift mode 88 | "min_shifts_per_week": 4, 89 | "max_hours_per_week": 29, 90 | "max_shift_length": 8, 91 | "max_shifts_per_week": 5, 92 | "min_hours_per_week": 20, 93 | "id": 3, 94 | "hours_between_shifts": 12, 95 | "day_week_starts": "monday", 96 | "min_shift_length": 4, 97 | } 98 | } 99 | ``` 100 | 101 | ## Response Format 102 | This is what the JSON callback from the Manager to the App API looks like: 103 | 104 | ``` 105 | DELETE 106 | { 107 | "solver_hash": "12lfls3lf" // Git hash of solver version used. 108 | } 109 | ``` 110 | 111 | ## Environment Variables 112 | 113 | The system defaults to a development environment where no environment variables are necessary. 114 | 115 | * `ENV` - either 'dev', 'stage', or 'prod' 116 | * `API_KEY` - a persistent api key associated with a Sudo-level user on [Staffjoy Suite](https://github.com/staffjoy/suite). 117 | * (optional) `SLEEP` - seconds between fetches to the tasking api. Defaults to 60 seconds in stage/prod. 118 | -------------------------------------------------------------------------------- /StaffJoy/test_data/courier-bike-1.jl: -------------------------------------------------------------------------------- 1 | test_bike_week_1_env = { 2 | "shift_time_min" => 3, 3 | "shift_time_max" => 8, 4 | "coverage" => Array[ 5 | # Monday 6 | [3, 4, 5 * ones(Int, 6), 4 * ones(Int, 2), 2 * ones(Int, 2)], 7 | # Tuesday 8 | [3, 4, 5 * ones(Int, 6), 4 * ones(Int, 2), 2 * ones(Int, 2)], 9 | # Wednesday 10 | [3, 4, 5 * ones(Int, 6), 4 * ones(Int, 2), 2 * ones(Int, 2)], 11 | # Thursday 12 | [3, 4, 5 * ones(Int, 6), 4 * ones(Int, 2), 2 * ones(Int, 2)], 13 | # Friday 14 | [3, 4, 5 * ones(Int, 6), 4 * ones(Int, 2), 2 * ones(Int, 2)], 15 | # Saturday 16 | [1, 2 * ones(Int, 11)], 17 | # Sunday 18 | [1, 2 * ones(Int, 11)], 19 | ], 20 | "time_between_coverage" => 12, 21 | "intershift" => 13, # Time periods between shifts # only serve 1 shift/day 22 | } 23 | 24 | # Coverage requires 291 man hours 25 | 26 | test_bike_week_1_employees = { 27 | "Cody" => { 28 | "hours_min" => 18, # hours worked over simulation 29 | "hours_max" => 32, # hours worked over simulation 30 | "shift_time_min" => 4, 31 | "shift_count_min" => 3, 32 | "shift_count_max" => 5, 33 | "availability" => Array[ 34 | [ones(Int, 12)], 35 | [ones(Int, 12)], 36 | [ones(Int, 12)], 37 | [ones(Int, 12)], 38 | [ones(Int, 12)], 39 | [ones(Int, 12)], 40 | [ones(Int, 12)], 41 | ], 42 | }, 43 | "Damian" => { 44 | "hours_min" => 12, # hours worked over simulation 45 | "hours_max" => 28, # hours worked over simulation 46 | "intershift" => 4, 47 | "shift_count_min" => 2, 48 | "shift_count_max" => 4, 49 | "availability" => Array[ 50 | [ones(Int, 5), zeros(Int, 7)], 51 | [ones(Int, 5), zeros(Int, 7)], 52 | [ones(Int, 5), zeros(Int, 7)], 53 | [ones(Int, 5), zeros(Int, 7)], 54 | [ones(Int, 5), zeros(Int, 7)], 55 | [zeros(Int, 12)], 56 | [zeros(Int, 12)], 57 | ], 58 | }, 59 | "David" => { 60 | "hours_min" => 12, # hours worked over simulation 61 | "hours_max" => 18, # hours worked over simulation 62 | "shift_count_min" => 2, 63 | "shift_count_max" => 3, 64 | "availability" => Array[ 65 | [zeros(Int, 12)], 66 | [zeros(Int, 2), ones(Int, 10)], 67 | [zeros(Int, 2), ones(Int, 10)], 68 | [zeros(Int, 2), ones(Int, 10)], 69 | [zeros(Int, 12)], 70 | [zeros(Int, 12)], 71 | [zeros(Int, 12)], 72 | ], 73 | }, 74 | "Douglas" => { 75 | "hours_min" => 12, # hours worked over simulation 76 | "hours_max" => 28, # hours worked over simulation 77 | "shift_count_min" => 2, 78 | "shift_count_max" => 4, 79 | "availability" => Array[ 80 | [ones(Int, 12)], 81 | [ones(Int, 12)], 82 | [ones(Int, 12)], 83 | [ones(Int, 12)], 84 | [ones(Int, 12)], 85 | [ones(Int, 12)], 86 | [ones(Int, 12)], 87 | ], 88 | }, 89 | "Dylan" => { 90 | # deliberately no shift count 91 | "hours_min" => 28, # hours worked over simulation 92 | "hours_max" => 32, # hours worked over simulation 93 | "shift_time_min" => 6, 94 | "shift_count_min" => 4, 95 | "shift_count_max" => 5, 96 | "availability" => Array[ 97 | [ones(Int, 7), zeros(Int, 5)], 98 | [zeros(Int, 12)], 99 | [zeros(Int, 12)], 100 | [ones(Int, 7), zeros(Int, 5)], 101 | [ones(Int, 7), zeros(Int, 5)], 102 | [ones(Int, 7), zeros(Int, 5)], 103 | [ones(Int, 7), zeros(Int, 5)], 104 | ], 105 | }, 106 | "Jaeger" => { 107 | "hours_min" => 18, # hours worked over simulation 108 | "hours_max" => 32, # hours worked over simulation 109 | "shift_count_min" => 3, 110 | "shift_count_max" => 5, 111 | "availability" => Array[ 112 | [ones(Int, 12)], 113 | [ones(Int, 12)], 114 | [ones(Int, 12)], 115 | [ones(Int, 12)], 116 | [ones(Int, 12)], 117 | [ones(Int, 12)], 118 | [ones(Int, 12)], 119 | ], 120 | }, 121 | "Julian" => { 122 | "hours_min" => 28, # hours worked over simulation 123 | "hours_max" => 32, # hours worked over simulation 124 | "shift_count_min" => 4, 125 | "shift_count_max" => 5, 126 | "availability" => Array[ 127 | [ones(Int, 12)], 128 | [ones(Int, 12)], 129 | [ones(Int, 12)], 130 | [ones(Int, 12)], 131 | [ones(Int, 12)], 132 | [ones(Int, 12)], 133 | [zeros(Int, 4), ones(Int, 8)], 134 | ], 135 | }, 136 | "Leonardo" => { 137 | "hours_min" => 18, # hours worked over simulation 138 | "hours_max" => 32, # hours worked over simulation 139 | "shift_count_min" => 3, 140 | "shift_count_max" => 5, 141 | "availability" => Array[ 142 | [ones(Int, 7), zeros(Int, 5)], 143 | [ones(Int, 7), zeros(Int, 5)], 144 | [ones(Int, 7), zeros(Int, 5)], 145 | [ones(Int, 7), zeros(Int, 5)], 146 | [ones(Int, 7), zeros(Int, 5)], 147 | [ones(Int, 7), zeros(Int, 5)], 148 | [ones(Int, 7), zeros(Int, 5)], 149 | ], 150 | }, 151 | "Patrick" => { 152 | "hours_min" => 12, # hours worked over simulation 153 | "hours_max" => 32, # hours worked over simulation 154 | "shift_count_min" => 2, 155 | "shift_count_max" => 5, 156 | "availability" => Array[ 157 | [ones(Int, 12)], 158 | [ones(Int, 12)], 159 | [ones(Int, 12)], 160 | [ones(Int, 12)], 161 | [ones(Int, 12)], 162 | [ones(Int, 12)], 163 | [ones(Int, 12)], 164 | ], 165 | }, 166 | "R" => { 167 | "hours_min" => 12, # hours worked over simulation 168 | "hours_max" => 32, # hours worked over simulation 169 | "shift_count_min" => 2, 170 | "shift_count_max" => 5, 171 | "availability" => Array[ 172 | [ones(Int, 8), zeros(Int, 4)], 173 | [ones(Int, 8), zeros(Int, 4)], 174 | [ones(Int, 8), zeros(Int, 4)], 175 | [ones(Int, 8), zeros(Int, 4)], 176 | [ones(Int, 8), zeros(Int, 4)], 177 | [ones(Int, 12)], 178 | [ones(Int, 12)], 179 | ], 180 | }, 181 | "Shaun" => { 182 | "hours_min" => 18, # hours worked over simulation 183 | "hours_max" => 32, # hours worked over simulation 184 | "shift_count_min" => 3, 185 | "shift_count_max" => 5, 186 | "availability" => Array[ 187 | [ones(Int, 12)], 188 | [ones(Int, 12)], 189 | [ones(Int, 12)], 190 | [ones(Int, 12)], 191 | [ones(Int, 12)], 192 | [zeros(Int, 2), ones(Int, 10)], 193 | [zeros(Int, 2), ones(Int, 10)], 194 | ], 195 | }, 196 | } 197 | -------------------------------------------------------------------------------- /StaffJoy/test_data/courier-incorrect-availability-length.jl: -------------------------------------------------------------------------------- 1 | test_bike_incorrect_availability_length_env = { 2 | "shift_time_min" => 3, 3 | "shift_time_max" => 8, 4 | "coverage" => Array[ 5 | # Monday 6 | [3, 4, 5 * ones(Int, 6), 4 * ones(Int, 2), 2 * ones(Int, 2)], 7 | # Tuesday 8 | [3, 4, 5 * ones(Int, 6), 4 * ones(Int, 2), 2 * ones(Int, 2)], 9 | # Wednesday 10 | [3, 4, 5 * ones(Int, 6), 4 * ones(Int, 2), 2 * ones(Int, 2)], 11 | # Thursday 12 | [3, 4, 5 * ones(Int, 6), 4 * ones(Int, 2), 2 * ones(Int, 2)], 13 | # Friday 14 | [3, 4, 5 * ones(Int, 6), 4 * ones(Int, 2), 2 * ones(Int, 2)], 15 | # Saturday 16 | [1, 2 * ones(Int, 11)], 17 | # Sunday 18 | [1, 2 * ones(Int, 11)], 19 | ], 20 | "time_between_coverage" => 12, 21 | "intershift" => 13, # Time periods between shifts # only serve 1 shift/day 22 | } 23 | 24 | # Coverage requires 291 man hours 25 | 26 | test_bike_incorrect_availability_length_employees = { 27 | "Cody" => { 28 | "hours_min" => 18, # hours worked over simulation 29 | "hours_max" => 32, # hours worked over simulation 30 | "shift_time_min" => 4, 31 | "shift_count_min" => 3, 32 | "shift_count_max" => 5, 33 | "availability" => Array[ 34 | [ones(Int, 12)], 35 | [ones(Int, 12)], 36 | # One missing here :-P 37 | [ones(Int, 12)], 38 | [ones(Int, 12)], 39 | [ones(Int, 12)], 40 | [ones(Int, 12)], 41 | ], 42 | }, 43 | "Damian" => { 44 | "hours_min" => 12, # hours worked over simulation 45 | "hours_max" => 28, # hours worked over simulation 46 | "intershift" => 4, 47 | "shift_count_min" => 2, 48 | "shift_count_max" => 4, 49 | "availability" => Array[ 50 | [ones(Int, 5), zeros(Int, 7)], 51 | [ones(Int, 5), zeros(Int, 7)], 52 | [ones(Int, 5), zeros(Int, 7)], 53 | [ones(Int, 5), zeros(Int, 7)], 54 | [ones(Int, 5), zeros(Int, 7)], 55 | [zeros(Int, 12)], 56 | [zeros(Int, 12)], 57 | ], 58 | }, 59 | "David" => { 60 | "hours_min" => 12, # hours worked over simulation 61 | "hours_max" => 18, # hours worked over simulation 62 | "shift_count_min" => 2, 63 | "shift_count_max" => 3, 64 | "availability" => Array[ 65 | [zeros(Int, 12)], 66 | [zeros(Int, 2), ones(Int, 10)], 67 | [zeros(Int, 2), ones(Int, 10)], 68 | [zeros(Int, 2), ones(Int, 10)], 69 | [zeros(Int, 12)], 70 | [zeros(Int, 12)], 71 | [zeros(Int, 12)], 72 | ], 73 | }, 74 | "Douglas" => { 75 | "hours_min" => 12, # hours worked over simulation 76 | "hours_max" => 28, # hours worked over simulation 77 | "shift_count_min" => 2, 78 | "shift_count_max" => 4, 79 | "availability" => Array[ 80 | [ones(Int, 12)], 81 | [ones(Int, 12)], 82 | [ones(Int, 12)], 83 | [ones(Int, 12)], 84 | [ones(Int, 12)], 85 | [ones(Int, 12)], 86 | [ones(Int, 12)], 87 | ], 88 | }, 89 | "Dylan" => { 90 | # deliberately no shift count 91 | "hours_min" => 28, # hours worked over simulation 92 | "hours_max" => 32, # hours worked over simulation 93 | "shift_time_min" => 6, 94 | "shift_count_min" => 4, 95 | "shift_count_max" => 5, 96 | "availability" => Array[ 97 | [ones(Int, 7), zeros(Int, 5)], 98 | [zeros(Int, 12)], 99 | [zeros(Int, 12)], 100 | [ones(Int, 7), zeros(Int, 5)], 101 | [ones(Int, 7), zeros(Int, 5)], 102 | [ones(Int, 7), zeros(Int, 5)], 103 | [ones(Int, 7), zeros(Int, 5)], 104 | ], 105 | }, 106 | "Jaeger" => { 107 | "hours_min" => 18, # hours worked over simulation 108 | "hours_max" => 32, # hours worked over simulation 109 | "shift_count_min" => 3, 110 | "shift_count_max" => 5, 111 | "availability" => Array[ 112 | [ones(Int, 12)], 113 | [ones(Int, 12)], 114 | [ones(Int, 12)], 115 | [ones(Int, 12)], 116 | [ones(Int, 12)], 117 | [ones(Int, 12)], 118 | [ones(Int, 12)], 119 | ], 120 | }, 121 | "Julian" => { 122 | "hours_min" => 28, # hours worked over simulation 123 | "hours_max" => 32, # hours worked over simulation 124 | "shift_count_min" => 4, 125 | "shift_count_max" => 5, 126 | "availability" => Array[ 127 | [ones(Int, 12)], 128 | [ones(Int, 12)], 129 | [ones(Int, 12)], 130 | [ones(Int, 12)], 131 | [ones(Int, 12)], 132 | [ones(Int, 12)], 133 | [zeros(Int, 4), ones(Int, 8)], 134 | ], 135 | }, 136 | "Leonardo" => { 137 | "hours_min" => 18, # hours worked over simulation 138 | "hours_max" => 32, # hours worked over simulation 139 | "shift_count_min" => 3, 140 | "shift_count_max" => 5, 141 | "availability" => Array[ 142 | [ones(Int, 7), zeros(Int, 5)], 143 | [ones(Int, 7), zeros(Int, 5)], 144 | [ones(Int, 7), zeros(Int, 5)], 145 | [ones(Int, 7), zeros(Int, 5)], 146 | [ones(Int, 7), zeros(Int, 5)], 147 | [ones(Int, 7), zeros(Int, 5)], 148 | [ones(Int, 7), zeros(Int, 5)], 149 | ], 150 | }, 151 | "Patrick" => { 152 | "hours_min" => 12, # hours worked over simulation 153 | "hours_max" => 32, # hours worked over simulation 154 | "shift_count_min" => 2, 155 | "shift_count_max" => 5, 156 | "availability" => Array[ 157 | [ones(Int, 12)], 158 | [ones(Int, 12)], 159 | [ones(Int, 12)], 160 | [ones(Int, 12)], 161 | [ones(Int, 12)], 162 | [ones(Int, 12)], 163 | [ones(Int, 12)], 164 | ], 165 | }, 166 | "Rufus" => { 167 | "hours_min" => 12, # hours worked over simulation 168 | "hours_max" => 32, # hours worked over simulation 169 | "shift_count_min" => 2, 170 | "shift_count_max" => 5, 171 | "availability" => Array[ 172 | [ones(Int, 8), zeros(Int, 4)], 173 | [ones(Int, 8), zeros(Int, 4)], 174 | [ones(Int, 8), zeros(Int, 4)], 175 | [ones(Int, 8), zeros(Int, 4)], 176 | [ones(Int, 8), zeros(Int, 4)], 177 | [ones(Int, 12)], 178 | [ones(Int, 12)], 179 | ], 180 | }, 181 | "Shaun" => { 182 | "hours_min" => 18, # hours worked over simulation 183 | "hours_max" => 32, # hours worked over simulation 184 | "shift_count_min" => 3, 185 | "shift_count_max" => 5, 186 | "availability" => Array[ 187 | [ones(Int, 12)], 188 | [ones(Int, 12)], 189 | [ones(Int, 12)], 190 | [ones(Int, 12)], 191 | [ones(Int, 12)], 192 | [zeros(Int, 2), ones(Int, 10)], 193 | [zeros(Int, 2), ones(Int, 10)], 194 | ], 195 | }, 196 | } 197 | -------------------------------------------------------------------------------- /StaffJoy/unit_tests/helpers_test.jl: -------------------------------------------------------------------------------- 1 | function helpers_test_unit() 2 | test_longest_consecutive() 3 | test_anneal_availability() 4 | test_get_day_bounds() 5 | test_get_longest_availability() 6 | test_sub_array() 7 | test_days_available_per_week() 8 | test_day_sum_coverage() 9 | test_week_sum_coverage() 10 | test_day_hours_scheduled() 11 | test_week_hours_scheduled() 12 | end 13 | 14 | function test_longest_consecutive() 15 | test_cases = [ 16 | # (input, expected) 17 | ([1 1 1 1 1 0 0 1 1], 5), 18 | ([0 1 1 1 1 0 0 1 1], 4), 19 | (zeros(Int, 8), 0), 20 | (ones(Int, 10), 10), 21 | ([1 1 1 0 1 1 1 0 0 1 1], 3), 22 | ([1 1 0 1 1 1 0 0 1 1], 3), 23 | ([ones(Int, 12)], 12) 24 | ] 25 | 26 | for case in test_cases 27 | input, expected = case 28 | @test longest_consecutive(input) == expected 29 | end 30 | end 31 | 32 | 33 | function test_anneal_availability() 34 | test_cases = [ 35 | # (avail, length, expected) 36 | ( 37 | [1 1 1 1 1 0 0 1 1 0], 38 | 5, 39 | [1 1 1 1 1 0 0 0 0 0] 40 | ), 41 | ([1 1 1 1 1 0 0 1 1], 5, [1 1 1 1 1 0 0 0 0]), 42 | ([1 1 1 1 1 0 1 1 1], 3, [1 1 1 1 1 0 1 1 1]), 43 | ([1 1 1 1], 4, [1 1 1 1]), 44 | ([1 1 1 1], 3, [1 1 1 1]), 45 | ([1 1 1 1 0 0 0 0 1 1 1 1 0 0 0], 4, [1 1 1 1 0 0 0 0 1 1 1 1 0 0 0]), 46 | ([1, 1, 1, 1, 0, 0, 0, 0, 1, 1, 1, 1, 0, 0, 0], 5, zeros(Int, 15)), 47 | ([0, 0, 1, 1, 1, 0, 1, 0], 3, [0, 0, 1, 1, 1, 0, 0, 0]), 48 | ([zeros(Int, 2), ones(Int, 10)], 10, [zeros(Int, 2), ones(Int, 10)]), 49 | ( 50 | [[1, 1, 1, 1, 1, 0, 0, 1, 1, 0] [1, 1, 1, 1, 1, 0, 0, 1, 1, 0];], 51 | 5, 52 | [[1, 1, 1, 1, 1, 0, 0, 0, 0, 0] [1, 1, 1, 1, 1, 0, 0, 0, 0, 0]] 53 | ), 54 | ] 55 | 56 | for case in test_cases 57 | avail, length, expected = case 58 | @test anneal_availability(avail, length) == expected 59 | end 60 | end 61 | 62 | function test_get_day_bounds() 63 | # Test a week where min requires you to assign full amount 64 | # to each day 65 | min, max = get_day_bounds( 66 | [8, 4, 3, 3, 3], 67 | [1, 1, 1, 1, 1], 68 | 0, 69 | 20, 70 | 23, 71 | 3 72 | ) 73 | 74 | @test max == 8 75 | @test min == 7 76 | 77 | min, max = get_day_bounds( 78 | [4, 3, 3, 3], 79 | [1, 1, 1, 1], 80 | 7, 81 | 20, 82 | 23, 83 | 3 84 | ) 85 | 86 | @test max == 4 87 | @test min == 4 88 | 89 | min, max = get_day_bounds( 90 | [3], 91 | [1], 92 | 19, 93 | 20, 94 | 23, 95 | 3 96 | ) 97 | 98 | @test max == 3 99 | @test min == 3 100 | 101 | min, max = get_day_bounds( 102 | [5, 8, 8, 8], 103 | [1, 1, 0, 1], 104 | 0, 105 | 0, 106 | 9, 107 | 3, 108 | ) 109 | 110 | @test max == 3 111 | @test min == 3 112 | 113 | min, max = get_day_bounds( 114 | [8, 8, 8], 115 | [1, 0, 1], 116 | 3, 117 | 0, 118 | 9, 119 | 3, 120 | ) 121 | 122 | @test max == 3 123 | @test min == 3 124 | 125 | min, max = get_day_bounds( 126 | [8], 127 | [1], 128 | 6, 129 | 0, 130 | 9, 131 | 3, 132 | ) 133 | 134 | @test max == 3 135 | @test min == 3 136 | 137 | end 138 | 139 | function test_get_longest_availability() 140 | day = 1 141 | employee = { 142 | "availability" => Array[ 143 | [0, 1, 1, 1, 1, 0], # Don't trigger bounds 144 | [0, 1, 1, 0, 0, 0], # trigger min 145 | [1, 1, 1, 1, 1, 1], # trigger max 146 | ], 147 | "shift_time_min" => 3, 148 | "shift_time_max" => 5, 149 | } 150 | 151 | @test get_longest_availability(employee, 1) == 4 152 | @test get_longest_availability(employee, 2) == 0 153 | @test get_longest_availability(employee, 3) == employee["shift_time_max"] 154 | 155 | end 156 | 157 | function test_sub_array() 158 | array = [1, 2, 3, 4, 5] 159 | 160 | @test sub_array(array, 1, 5) == [1, 2, 3, 4, 5] 161 | @test sub_array(array, 2, 1) == [2, 3, 4, 5, 1] 162 | @test sub_array(array, 3, 2) == [3, 4, 5, 1, 2] 163 | @test sub_array(array, 1, 1) == [1] 164 | end 165 | 166 | function test_days_available_per_week() 167 | lenny = { 168 | "shift_time_min" => 4, 169 | "shift_time_max" => 6, 170 | "hours_min" => 16, # hours worked over simulation 171 | "hours_max" => 25, # hours worked over simulation 172 | "shift_count_min" => 4, 173 | "shift_count_max" => 5, 174 | "availability" => Array[ 175 | [ones(Int, 7), zeros(Int, 5)], 176 | [ones(Int, 7), zeros(Int, 5)], 177 | [zeros(Int, 12)], 178 | [ones(Int, 7), zeros(Int, 5)], 179 | [zeros(Int, 5), ones(Int, 3), zeros(Int, 4)], # Annealed out 180 | [zeros(Int, 12)], 181 | [zeros(Int, 12)], 182 | ], 183 | } 184 | 185 | @test days_available_per_week(lenny) == 3 186 | end 187 | 188 | function test_day_sum_coverage() 189 | test_env = { 190 | "coverage" => [3, 4, 5 * ones(Int, 6), 4 * ones(Int, 2), 2 * ones(Int, 2)], 191 | "cycle_length" => 12, 192 | } 193 | expected = 49 194 | @test day_sum_coverage(test_env) == expected 195 | end 196 | 197 | function test_week_sum_coverage() 198 | test_env = { 199 | "shift_time_min" => 3, 200 | "shift_time_max" => 8, 201 | "coverage" => Array[ 202 | # Monday 203 | [3, 4, 5 * ones(Int, 6), 4 * ones(Int, 2), 2 * ones(Int, 2)], 204 | # Tuesday 205 | [3, 4, 5 * ones(Int, 6), 4 * ones(Int, 2), 2 * ones(Int, 2)], 206 | # Wednesday 207 | [3, 4, 5 * ones(Int, 6), 4 * ones(Int, 2), 2 * ones(Int, 2)], 208 | # Thursday 209 | [3, 4, 5 * ones(Int, 6), 4 * ones(Int, 2), 2 * ones(Int, 2)], 210 | # Friday 211 | [3, 4, 5 * ones(Int, 6), 4 * ones(Int, 2), 2 * ones(Int, 2)], 212 | # Saturday 213 | [1, 2 * ones(Int, 11)], 214 | # Sunday 215 | [1, 2 * ones(Int, 11)], 216 | ], 217 | "time_between_coverage" => 12, 218 | "intershift" => 13, # Time periods between shifts # only serve 1 shift/day 219 | } 220 | 221 | expected = 49*5 + 23*2 222 | @test week_sum_coverage(test_env) == expected 223 | end 224 | function test_day_hours_scheduled() 225 | schedule = { 226 | "bob" => { 227 | "start" => 8, 228 | "length" => 13, 229 | }, 230 | "joe" => { 231 | "start" => 1, 232 | "length" => 30, 233 | } 234 | } 235 | 236 | expected = 43 237 | @test day_hours_scheduled(schedule) == expected 238 | end 239 | function test_week_hours_scheduled() 240 | schedule = { 241 | "bob" => [ 242 | { 243 | "start" => 8, 244 | "length" => 13, 245 | "day" => 2, 246 | }, 247 | { 248 | "start" => 0, 249 | "length" => 3, 250 | "day" => 2, 251 | }, 252 | ], 253 | "billy" => [ 254 | { 255 | "start" => 8, 256 | "length" => 19, 257 | "day" => 2, 258 | }, 259 | { 260 | "start" => 0, 261 | "length" => 3, 262 | "day" => 2, 263 | }, 264 | ], 265 | } 266 | 267 | expected = 38 268 | @test week_hours_scheduled(schedule) == expected 269 | end 270 | -------------------------------------------------------------------------------- /StaffJoy/serial_schedulers.jl: -------------------------------------------------------------------------------- 1 | function schedule_by_day(config) 2 | # "config" is dict with keys start_day and method 3 | start_day = config["start_day"] 4 | method = config["method"] 5 | employees = config["employees"] 6 | env = config["env"] 7 | perfect_optimality = week_sum_coverage(env) 8 | 9 | if (method == "balanced") 10 | ok, weekly_schedule = schedule_by_day_balanced(employees, env, start_day) 11 | elseif (method == "greedy") 12 | ok, weekly_schedule = schedule_by_day_greedy(employees, env, start_day) 13 | else 14 | warn("Unknown method $method") 15 | return {"success" => false} 16 | end 17 | if ok 18 | valid_schedule, message = validate_schedule(employees, env, weekly_schedule) 19 | if !valid_schedule 20 | Logging.info( "Unexpected validation issue in $method scheduler - $message") 21 | return {"success" => false} 22 | end 23 | hours_scheduled = week_hours_scheduled(weekly_schedule) 24 | overage = (hours_scheduled - perfect_optimality) / perfect_optimality 25 | Logging.info( "$method day scheduler succeeded - overage $overage ") 26 | 27 | return { 28 | "success" => true, 29 | "schedule" => weekly_schedule, 30 | } 31 | end 32 | return {"success" => false} 33 | end 34 | 35 | 36 | function schedule_by_day_balanced(employees, env, start_day=1) 37 | days_per_week = size(env["coverage"], 1) 38 | hours_scheduled = Dict() 39 | weekly_schedule = Dict() 40 | for e in keys(employees) 41 | hours_scheduled[e] = 0 42 | weekly_schedule[e] = Any[] 43 | end 44 | 45 | end_day = (start_day + days_per_week - 2) % days_per_week + 1 46 | 47 | for day_index in start_day:(days_per_week + start_day - 1) 48 | # stupid 1-indexing 49 | # Need to wrap around day so we can support different start days 50 | day = (day_index - 1) % days_per_week+ 1 51 | 52 | day_env = Dict() 53 | day_env["coverage"] = env["coverage"][day] 54 | day_env["cycle_length"] = length(env["coverage"][day]) 55 | 56 | day_employees = Dict() 57 | for e in keys(employees) 58 | if employees[e]["days_assigned"][day] != 1 59 | continue 60 | end 61 | 62 | day_min, day_max = get_day_bounds( 63 | sub_array(employees[e]["longest_availability"], day, end_day), 64 | sub_array(employees[e]["days_assigned"], day, end_day), 65 | hours_scheduled[e], 66 | employees[e]["hours_min"], 67 | employees[e]["hours_max"], 68 | employees[e]["shift_time_min"], 69 | ) 70 | 71 | day_employees[e] = Dict() 72 | day_employees[e]["min"] = day_min 73 | day_employees[e]["max"] = day_max 74 | day_employees[e]["availability"] = employees[e]["availability"][day] 75 | end 76 | 77 | ok, day_schedule = schedule_day(day_employees, day_env) 78 | if !ok 79 | return false, {} 80 | end 81 | 82 | for e in keys(day_schedule) 83 | push!(weekly_schedule[e], { 84 | "day" => day, 85 | "start" => day_schedule[e]["start"], 86 | "length" => day_schedule[e]["length"], 87 | }) 88 | hours_scheduled[e] += day_schedule[e]["length"] 89 | end 90 | 91 | ## INTERSHIFT 92 | # Adjust next day's availability 93 | if day == days_per_week 94 | # only do it if there is another day 95 | continue 96 | end 97 | 98 | for e in keys(day_schedule) 99 | # Note: this assumes that intershift < day_length 100 | # TODO - add a check in verify function for this 101 | next_day = day + 1 102 | if next_day > days_per_week 103 | next_day = (next_day - 1) % days_per_week + 1 104 | end 105 | 106 | if employees[e]["days_assigned"][next_day] != 1 107 | continue 108 | end 109 | # last time slot where the person works 110 | last_shift = day_schedule[e]["start"] + day_schedule[e]["length"] - 1 111 | hours_left_in_day = length(env["coverage"][day]) - last_shift 112 | 113 | if "intershift" in keys(employees[e]) 114 | intershift = employees[e]["intershift"] 115 | else 116 | intershift = env["intershift"] 117 | end 118 | 119 | overflow = intershift - hours_left_in_day - env["time_between_coverage"] 120 | 121 | if overflow <= 0 122 | continue 123 | end 124 | 125 | # adjust availability 126 | for t in 1:overflow 127 | employees[e]["availability"][next_day][t] = 0 128 | employees[e]["availability"][next_day] = anneal_availability(employees[e]["availability"][next_day], employees[e]["shift_time_min"]) 129 | end 130 | employees[e]["longest_availability"][next_day] = get_longest_availability(employees[e], next_day) 131 | end 132 | 133 | end 134 | 135 | return true, weekly_schedule 136 | end 137 | 138 | #= 139 | This scheduler does not account for future availability of employees. 140 | Instead, when it reaches an infeasibility it tries to *drop* employee 141 | shifts. This may behoove our algorithm because it tends to assign people 142 | to the upper bound of their shift limits. 143 | =# 144 | function schedule_by_day_greedy(employees, env, start_day=1) 145 | days_per_week = size(env["coverage"], 1) 146 | hours_scheduled = Dict() 147 | weekly_schedule = Dict() 148 | shifts_scheduled = Dict() 149 | for e in keys(employees) 150 | hours_scheduled[e] = 0 151 | weekly_schedule[e] = Any[] 152 | shifts_scheduled[e] = sum(employees[e]["days_assigned"]) 153 | end 154 | 155 | end_day = (start_day + days_per_week - 2) % days_per_week + 1 156 | 157 | for day_index in start_day:(days_per_week + start_day - 1) 158 | # stupid 1-indexing 159 | # Need to wrap around day so we can support different start days 160 | day = (day_index - 1) % days_per_week+ 1 161 | 162 | day_env = Dict() 163 | day_env["coverage"] = env["coverage"][day] 164 | day_env["cycle_length"] = length(env["coverage"][day]) 165 | 166 | day_employees = Dict() 167 | for e in keys(employees) 168 | if employees[e]["days_assigned"][day] != 1 169 | continue 170 | end 171 | 172 | shift_time_min = employees[e]["shift_time_min"] 173 | shift_time_max = employees[e]["shift_time_max"] 174 | hours_min = employees[e]["hours_min"] 175 | hours_max = employees[e]["hours_max"] 176 | 177 | if ( 178 | # Worked enough hours already 179 | hours_scheduled[e] >= hours_min && 180 | hours_scheduled[e] < hours_max 181 | ) 182 | # Either adjust the shift 183 | remaining_hours = hours_max - hours_scheduled[e] 184 | if (remaining_hours >= shift_time_min) 185 | # only adjust if necessary 186 | if (remaining_hours < shift_time_max) 187 | shift_time_max = remaining_hours 188 | end 189 | # otherwise don't touch 190 | else # Try dropping the shift 191 | if shifts_scheduled[e] > employees[e]["shift_time_min"] 192 | shifts_scheduled[e] -= 1 193 | continue # will this work? 194 | else 195 | # We're fucked 196 | return false, {} 197 | end 198 | end 199 | end 200 | 201 | day_employees[e] = Dict() 202 | day_employees[e]["min"] = shift_time_min 203 | day_employees[e]["max"] = shift_time_max 204 | day_employees[e]["availability"] = employees[e]["availability"][day] 205 | end 206 | 207 | ok, day_schedule = schedule_day(day_employees, day_env) 208 | if !ok 209 | return false, {} 210 | end 211 | 212 | for e in keys(day_schedule) 213 | push!(weekly_schedule[e], { 214 | "day" => day, 215 | "start" => day_schedule[e]["start"], 216 | "length" => day_schedule[e]["length"], 217 | }) 218 | hours_scheduled[e] += day_schedule[e]["length"] 219 | end 220 | 221 | ## INTERSHIFT 222 | # Adjust next day's availability 223 | if day == days_per_week 224 | # only do it if there is another day 225 | continue 226 | end 227 | 228 | for e in keys(day_schedule) 229 | # Note: this assumes that intershift < day_length 230 | # TODO - add a check in verify function for this 231 | next_day = day + 1 232 | if next_day > days_per_week 233 | next_day = (next_day - 1) % days_per_week + 1 234 | end 235 | 236 | if employees[e]["days_assigned"][next_day] != 1 237 | continue 238 | end 239 | # last time slot where the person works 240 | last_shift = day_schedule[e]["start"] + day_schedule[e]["length"] - 1 241 | hours_left_in_day = length(env["coverage"][day]) - last_shift 242 | 243 | if "intershift" in keys(employees[e]) 244 | intershift = employees[e]["intershift"] 245 | else 246 | intershift = env["intershift"] 247 | end 248 | 249 | overflow = intershift - hours_left_in_day - env["time_between_coverage"] 250 | 251 | if overflow <= 0 252 | continue 253 | end 254 | 255 | # adjust availability 256 | for t in 1:overflow 257 | employees[e]["availability"][next_day][t] = 0 258 | employees[e]["availability"][next_day] = anneal_availability(employees[e]["availability"][next_day], employees[e]["shift_time_min"]) 259 | end 260 | employees[e]["longest_availability"][next_day] = get_longest_availability(employees[e], next_day) 261 | end 262 | 263 | end 264 | 265 | return true, weekly_schedule 266 | end 267 | -------------------------------------------------------------------------------- /StaffJoy/day.jl: -------------------------------------------------------------------------------- 1 | function schedule_day(employees, env) 2 | ok, message = validate_day(employees, env) 3 | if !ok 4 | Logging.info( message) 5 | return false, {} 6 | end 7 | 8 | ok, schedule = calculate_day(employees, env) 9 | if !ok 10 | return false, {} 11 | end 12 | 13 | # again a boolean "ok" followed by message 14 | ok, message = validate_day_schedule(employees, env, schedule) 15 | if !ok 16 | Logging.info( message) 17 | return false, {} 18 | end 19 | 20 | # boolean "ok" followed by data 21 | return true, schedule 22 | end 23 | 24 | function calculate_day(employees, env) 25 | total_time = length(env["coverage"]) 26 | num_employees = length(employees) 27 | 28 | sum_coverage = sum(env["coverage"]) 29 | 30 | if GUROBI 31 | m = Model( 32 | solver=GurobiSolver( 33 | LogToConsole=0, 34 | TimeLimit=CALCULATION_TIMEOUT, 35 | OutputFlag=0, 36 | ) 37 | ) 38 | else 39 | m = Model( 40 | solver=CbcSolver( 41 | threads=Base.CPU_CORES, 42 | ) 43 | ) 44 | end 45 | 46 | ########################################################################### 47 | # SHIFTS 48 | ########################################################################### 49 | 50 | # Build variable 51 | shift_start_max = Uint8[] 52 | length_max = Uint8[] 53 | length_min = Uint8[] 54 | worker_keys = Any[] 55 | for e in keys(employees) # e is a string 56 | push!(shift_start_max, total_time + 1 - employees[e]["min"]) 57 | push!(length_max, employees[e]["max"]) 58 | push!(length_min, employees[e]["min"]) 59 | push!(worker_keys, e) 60 | end 61 | 62 | # Shift start time 63 | @defVar(m, 1 <= shift_start[1:num_employees] <= total_time, Int) 64 | for e in 1:num_employees # e is an int 65 | setUpper(shift_start[e], shift_start_max[e]) 66 | end 67 | # Shift length - max & min 68 | @defVar(m, shift_length[e=1:num_employees], Int) 69 | for e in 1:num_employees # e is an int 70 | setLower(shift_length[e], length_min[e]) 71 | setUpper(shift_length[e], length_max[e]) 72 | 73 | # The "delivery" constraint: 74 | # prevent unassigned shifts from starting after a certain time 75 | if "no_shifts_after" in keys(env) 76 | @addConstraint(m, 77 | shift_start[e] <= env["no_shifts_after"], 78 | ) 79 | end 80 | end 81 | 82 | 83 | 84 | 85 | ########################################################################### 86 | # BINARY CONVERSION 87 | ########################################################################### 88 | #= 89 | Set up binary variable for every employee for every week to determine 90 | whether somebody is working. This makes it easy to sum over the week and 91 | see how many people are working and whether s comply with availability. 92 | =# 93 | 94 | # build some helper variables to make my life more sane 95 | bin_total = total_time * num_employees 96 | i_to_t = Int[] 97 | i_to_e = Uint8[] # basically what is the equivalent s index 98 | i_employee = String[] 99 | #(could have used a modulus of cycle?) 100 | 101 | # we are stacking cycles together, offset by total_time 102 | # so all t=1 first, then all t=2, etc 103 | for t in 1:total_time 104 | e_index = 0 105 | for e in keys(employees) 106 | e_index+=1 107 | push!(i_to_t, t) 108 | push!(i_to_e, e_index) 109 | push!(i_employee, e) 110 | end 111 | end 112 | 113 | # This whole section is about converting a shift start time and length to 114 | # a binary array of whether it has started at time t and whether it is 115 | # active at time t 116 | 117 | # define variables 118 | @defVar(m, started[1:bin_total], Bin) 119 | @defVar(m, active[1:bin_total], Bin) 120 | 121 | # Need to split this into its constituent parts 122 | # (allows us to determine whether it is equal to zero, meaning 123 | # that the shift started) 124 | @defVar(m, 0 <= start_min_now_pos[1:bin_total] <= total_time, Int) 125 | @defVar(m, 0 <= start_min_now_neg[1:bin_total] <= total_time, Int) 126 | # Is it positive? 127 | @defVar(m, start_min_now_helper[1:bin_total], Bin) 128 | 129 | # Constraints 130 | for i in 1:bin_total 131 | # instead of SOS constraint on the start_min variables: 132 | # Use a big M approach 133 | M = total_time + 1 134 | @addConstraints m begin 135 | start_min_now_pos[i] <= M * start_min_now_helper[i] 136 | start_min_now_neg[i] <= M * (1 - start_min_now_helper[i]) 137 | start_min_now_pos[i] - start_min_now_neg[i] == shift_start[i_to_e[i]] - i_to_t[i] 138 | 1 <= start_min_now_pos[i] + start_min_now_neg[i] + started[i] 139 | end 140 | 141 | # Create one contiguous chunk of ones 142 | if i_to_t[i] == 1 143 | @addConstraint(m, 144 | started[i] == active[i], 145 | ) 146 | else # not first time 147 | @addConstraint(m, 148 | # basically "t-1" for same shift 149 | started[i] >= active[i] - active[i-num_employees], 150 | ) 151 | end 152 | end 153 | 154 | for e in 1:num_employees 155 | @addConstraints m begin 156 | # each shift can only have one start time 157 | 1 == sum{started[i], i=e:num_employees:bin_total} 158 | # Shift binary array must sum to length variable 159 | shift_length[e] == sum{active[i], i=e:num_employees:bin_total} 160 | end 161 | end 162 | 163 | ########################################################################### 164 | # AVAILABILITY CONSTRAINTS 165 | ########################################################################### 166 | 167 | for i in 1:bin_total 168 | # Employees dont' work when they are unavailable 169 | if employees[i_employee[i]]["availability"][i_to_t[i]] == 0 170 | @addConstraint(m, active[i] == 0) 171 | end 172 | end 173 | 174 | 175 | ########################################################################### 176 | # ENVIRONMENT CONSTRAINTS 177 | ########################################################################### 178 | 179 | # Make sure that the organization has the proper number of people working 180 | for t in 1:total_time 181 | # No coverage == nobody working 182 | if env["coverage"][t] > 0 183 | @addConstraint(m, 184 | env["coverage"][t] <= sum{active[i], i=1:bin_total; i_to_t[i] == t} 185 | ) 186 | else 187 | # nobody needed, so nobody should be working 188 | @addConstraint(m, 189 | 0 == sum{active[i], i=1:bin_total; i_to_t[i] == t} 190 | ) 191 | end 192 | end 193 | 194 | ########################################################################### 195 | # OPTIMIZATION 196 | ########################################################################### 197 | 198 | # Set the objective 199 | @setObjective(m, Min, sum{shift_length[e], e=1:num_employees}) 200 | 201 | 202 | # optimize 203 | status = solve(m) 204 | sumHours = 0 205 | if status == :Optimal 206 | hours = getObjectiveValue(m) 207 | Logging.debug("Day scheduler: scheduled $hours / min $sum_coverage") 208 | e_index = 0 209 | schedule = Dict() 210 | for e in keys(employees) 211 | e_index += 1 212 | # NOTE - we round here. If we're debugging - don't round. Might get weird floats. 213 | start = convert(Int, round(getValue(shift_start[e_index]))) 214 | length = convert(Int, round(getValue(shift_length[e_index]))) 215 | sumHours += length 216 | schedule[e] = { 217 | "start" => start, 218 | "length" => length, 219 | } 220 | end 221 | # TODO - build return function 222 | else 223 | Logging.debug( "No optimal solution found") 224 | gc() 225 | return false, {} 226 | end 227 | # ok 228 | gc() 229 | return true, schedule 230 | end 231 | 232 | function validate_day(employees, env) 233 | total_time = length(env["coverage"]) 234 | min_hours = 0 235 | max_hours = 0 236 | coverage_total = sum(env["coverage"]) 237 | 238 | for e in keys(employees) 239 | if length(employees[e]["availability"]) != total_time 240 | return false, string("Incorrect availability length for ", e) 241 | end 242 | 243 | longest = longest_consecutive(employees[e]["availability"]) 244 | 245 | if longest < employees[e]["min"] 246 | return false, string("employee ", e, " not available long enough") 247 | end 248 | 249 | if employees[e]["min"] > total_time 250 | return false, string("employee ", e, " has too long a min length") 251 | end 252 | 253 | min_hours += employees[e]["min"] 254 | max_hours += employees[e]["max"] 255 | end 256 | 257 | Logging.debug("Coverage $coverage_total / Min hours: $min_hours / Max hours: $max_hours") 258 | if coverage_total > max_hours 259 | return false, "coverage exceeds max employee hours" 260 | end 261 | 262 | # check that enough employees are available to work each shift 263 | for t in 1:total_time 264 | availability_sum = 0 265 | for e in keys(employees) 266 | availability_sum += employees[e]["availability"][t] 267 | end 268 | 269 | if availability_sum < env["coverage"][t] 270 | return false, string("not enough employees available at time ", t) 271 | end 272 | end 273 | 274 | return true, "42" 275 | end 276 | 277 | function validate_day_schedule(employees, env, schedule) 278 | total_time = length(env["coverage"]) 279 | 280 | # helper variable for later 281 | coverage_check = zeros(Int, total_time) 282 | 283 | for e in keys(employees) 284 | start = schedule[e]["start"] 285 | length = schedule[e]["length"] 286 | 287 | # shift start time >= 1 288 | if start < 1 289 | return false, string("employee ", e, " start time out of bounds") 290 | end 291 | 292 | if length <= 0 293 | return false, string("employee ", e, " shift length invalid") 294 | end 295 | 296 | # end time in bounds 297 | if (start + length - 1) > total_time # fucking non-zero-indices 298 | return false, string("employee ", e, " length out of bounds") 299 | end 300 | 301 | for t in start:(start + length - 1) 302 | # make sure employee is available then 303 | if employees[e]["availability"][t] == 0 304 | return false, string("employee ", e, " scheduled when unavailable") 305 | end 306 | # also add to coverage tab for checking later 307 | coverage_check[t] += 1 308 | end 309 | end 310 | 311 | for t in 1:total_time 312 | # make sure we have enough peeps working 313 | if coverage_check[t] < env["coverage"][t] 314 | return false, string("not enough people working at time ", t) 315 | end 316 | end 317 | return true, "" 318 | end 319 | -------------------------------------------------------------------------------- /Manager/unit_tests/filters.jl: -------------------------------------------------------------------------------- 1 | function test_filters() 2 | test_fill_missing_availability() 3 | test_downgrade_availability_noop() 4 | test_downgrade_shifts() 5 | test_downgrade_hours() 6 | test_downgrade_hours_and_shifts() 7 | test_only_available_when_business_closed_downgrade() 8 | end 9 | 10 | function test_fill_missing_availability() 11 | schedule = { 12 | "demand" => { 13 | "sunday" => [1, 2, 3, 3, 2, 1, 1, 1], 14 | "monday" => [1, 2, 3, 3, 2, 1, 1, 1], 15 | "tuesday" => [1, 2, 3, 3, 2, 1, 1, 1], 16 | "wednesday" => [1, 2, 3, 3, 2, 1, 1, 1], 17 | "thursday" => [1, 2, 3, 3, 2, 1, 1, 1], 18 | "friday" => [1, 2, 3, 3, 2, 1, 1, 1], 19 | "saturday" => [1, 2, 3, 3, 2, 1, 1, 1], 20 | }, 21 | } 22 | 23 | organization = { 24 | "day_week_starts" => "sunday", 25 | } 26 | 27 | workers = Any[ 28 | { 29 | "id" => 1, 30 | "name" => "Nikola Tesla", 31 | "max_shifts_per_week" => 2, 32 | }, 33 | { 34 | "id" => 2, 35 | "name" => "Richard Feynman", 36 | "max_shifts_per_week" => 2, 37 | }, 38 | { 39 | "id" => 3, 40 | "name" => "Dean Quatrano", 41 | "max_shifts_per_week" => 0, 42 | }, 43 | ] 44 | 45 | availability = Any[ 46 | { 47 | "user_id" => 2, 48 | "availability" => { 49 | "sunday" => [1, 0, 1, 1, 0, 1, 1, 1], 50 | "monday" => [1, 0, 1, 1, 0, 1, 1, 1], 51 | "tuesday" => [1, 0, 1, 1, 0, 1, 1, 1], 52 | "wednesday" => [1, 0, 1, 1, 0, 1, 1, 1], 53 | "thursday" => [1, 0, 1, 1, 0, 1, 1, 1], 54 | "friday" => [1, 0, 1, 1, 0, 1, 1, 1], 55 | "saturday" => [1, 0, 1, 1, 0, 1, 1, 1], 56 | }, 57 | } 58 | { 59 | "user_id" => 3, 60 | "availability" => { 61 | "sunday" => [1, 0, 0, 0, 0, 0, 1, 1], 62 | "monday" => [1, 0, 1, 1, 0, 1, 1, 1], 63 | "tuesday" => [1, 0, 1, 1, 0, 1, 1, 1], 64 | "wednesday" => [1, 0, 1, 1, 0, 1, 1, 1], 65 | "thursday" => [1, 0, 1, 1, 0, 1, 1, 1], 66 | "friday" => [1, 0, 1, 1, 0, 1, 1, 1], 67 | "saturday" => [1, 0, 1, 1, 0, 1, 1, 1], 68 | }, 69 | } 70 | ] 71 | 72 | expected = Any[ 73 | { 74 | "id" => 1, 75 | "name" => "Nikola Tesla", 76 | "max_shifts_per_week" => 2, 77 | "availability" => { 78 | "sunday" => [1, 1, 1, 1, 1, 1, 1, 1], 79 | "monday" => [1, 1, 1, 1, 1, 1, 1, 1], 80 | "tuesday" => [1, 1, 1, 1, 1, 1, 1, 1], 81 | "wednesday" => [1, 1, 1, 1, 1, 1, 1, 1], 82 | "thursday" => [1, 1, 1, 1, 1, 1, 1, 1], 83 | "friday" => [1, 1, 1, 1, 1, 1, 1, 1], 84 | "saturday" => [1, 1, 1, 1, 1, 1, 1, 1], 85 | }, 86 | }, 87 | { 88 | "id" => 2, 89 | "name" => "Richard Feynman", 90 | "max_shifts_per_week" => 2, 91 | "availability" => { 92 | "sunday" => [1, 0, 1, 1, 0, 1, 1, 1], 93 | "monday" => [1, 0, 1, 1, 0, 1, 1, 1], 94 | "tuesday" => [1, 0, 1, 1, 0, 1, 1, 1], 95 | "wednesday" => [1, 0, 1, 1, 0, 1, 1, 1], 96 | "thursday" => [1, 0, 1, 1, 0, 1, 1, 1], 97 | "friday" => [1, 0, 1, 1, 0, 1, 1, 1], 98 | "saturday" => [1, 0, 1, 1, 0, 1, 1, 1], 99 | }, 100 | }, 101 | ] 102 | 103 | actual = fill_missing_availability(schedule, availability, workers, organization, Dict(), false) 104 | 105 | @test expected == actual 106 | end 107 | 108 | 109 | # Everybody meets constraints - don't touch them 110 | function test_downgrade_availability_noop() 111 | schedule = { 112 | "demand" => { 113 | "sunday" => [1, 2, 3, 3, 2, 1, 1, 1], 114 | "monday" => [1, 2, 3, 3, 2, 1, 1, 1], 115 | "tuesday" => [1, 2, 3, 3, 2, 1, 1, 1], 116 | "wednesday" => [1, 2, 3, 3, 2, 1, 1, 1], 117 | "thursday" => [1, 2, 3, 3, 2, 1, 1, 1], 118 | "friday" => [1, 2, 3, 3, 2, 1, 1, 1], 119 | "saturday" => [1, 2, 3, 3, 2, 1, 1, 1], 120 | }, 121 | } 122 | 123 | organization = { 124 | "day_week_starts" => "sunday", 125 | } 126 | 127 | workers = Any[ 128 | { 129 | "id" => 1, 130 | "name" => "Nikola Tesla", 131 | "min_shift_length" => 1, 132 | "max_shift_length" => 9, 133 | "min_shifts_per_week" => 1, 134 | "max_shifts_per_week" => 2, 135 | "min_hours_per_week" => 1, 136 | "max_hours_per_week" => 20, 137 | "availability" => { 138 | "sunday" => [1, 1, 1, 1, 1, 1, 1, 1], 139 | "monday" => [1, 1, 1, 1, 1, 1, 1, 1], 140 | "tuesday" => [1, 1, 1, 1, 1, 1, 1, 1], 141 | "wednesday" => [1, 1, 1, 1, 1, 1, 1, 1], 142 | "thursday" => [1, 1, 1, 1, 1, 1, 1, 1], 143 | "friday" => [1, 1, 1, 1, 1, 1, 1, 1], 144 | "saturday" => [1, 1, 1, 1, 1, 1, 1, 1], 145 | }, 146 | }, 147 | { 148 | "id" => 2, 149 | "name" => "Richard Feynman", 150 | "min_shift_length" => 1, 151 | "max_shift_length" => 9, 152 | "min_shifts_per_week" => 1, 153 | "max_shifts_per_week" => 2, 154 | "min_hours_per_week" => 1, 155 | "max_hours_per_week" => 20, 156 | "availability" => { 157 | "sunday" => [1, 1, 1, 1, 1, 1, 1, 1], 158 | "monday" => [1, 1, 1, 1, 1, 1, 1, 1], 159 | "tuesday" => [1, 1, 1, 1, 1, 1, 1, 1], 160 | "wednesday" => [1, 1, 1, 1, 1, 1, 1, 1], 161 | "thursday" => [1, 1, 1, 1, 1, 1, 1, 1], 162 | "friday" => [1, 1, 1, 1, 1, 1, 1, 1], 163 | "saturday" => [1, 1, 1, 1, 1, 1, 1, 1], 164 | }, 165 | }, 166 | ] 167 | 168 | actual = downgrade_availability(schedule, workers, organization, Dict(), false) 169 | 170 | @test workers == actual 171 | end 172 | 173 | function test_downgrade_shifts() 174 | schedule = { 175 | "demand" => { 176 | "sunday" => [1, 2, 3, 3, 2, 1, 1, 1], 177 | "monday" => [1, 2, 3, 3, 2, 1, 1, 1], 178 | "tuesday" => [1, 2, 3, 3, 2, 1, 1, 1], 179 | "wednesday" => [1, 2, 3, 3, 2, 1, 1, 1], 180 | "thursday" => [1, 2, 3, 3, 2, 1, 1, 1], 181 | "friday" => [1, 2, 3, 3, 2, 1, 1, 1], 182 | "saturday" => [1, 2, 3, 3, 2, 1, 1, 1], 183 | }, 184 | } 185 | 186 | organization = { 187 | "day_week_starts" => "sunday", 188 | } 189 | 190 | workers = Any[ 191 | # Adjust this guy's shift max 192 | { 193 | "id" => 1, 194 | "name" => "Nikola Tesla", 195 | "min_shift_length" => 2, 196 | "max_shift_length" => 9, 197 | "min_shifts_per_week" => 1, 198 | "max_shifts_per_week" => 7, 199 | "min_hours_per_week" => 1, 200 | "max_hours_per_week" => 20, 201 | "availability" => { 202 | "sunday" => [0, 1, 0, 0, 0, 0, 0, 0], 203 | "monday" => [1, 1, 1, 1, 1, 1, 1, 1], 204 | "tuesday" => [1, 1, 1, 1, 1, 1, 1, 1], 205 | "wednesday" => [1, 1, 1, 1, 1, 1, 1, 1], 206 | "thursday" => [1, 1, 1, 1, 1, 1, 1, 1], 207 | "friday" => [1, 1, 1, 1, 1, 1, 1, 1], 208 | "saturday" => [1, 1, 1, 1, 1, 1, 1, 1], 209 | }, 210 | }, 211 | { # Adjust this guy's shift min and max 212 | "id" => 2, 213 | "name" => "Richard Feynman", 214 | "min_shift_length" => 1, 215 | "max_shift_length" => 9, 216 | "min_shifts_per_week" => 5, 217 | "max_shifts_per_week" => 7, 218 | "min_hours_per_week" => 1, 219 | "max_hours_per_week" => 20, 220 | "availability" => { 221 | "sunday" => [0, 0, 0, 0, 0, 0, 0, 0], 222 | "monday" => [0, 0, 0, 0, 0, 0, 0, 0], 223 | "tuesday" => [0, 0, 0, 0, 0, 0, 0, 0], 224 | "wednesday" => [1, 1, 1, 1, 1, 1, 1, 1], 225 | "thursday" => [1, 1, 1, 1, 1, 1, 1, 1], 226 | "friday" => [1, 1, 1, 1, 1, 1, 1, 1], 227 | "saturday" => [1, 1, 1, 1, 1, 1, 1, 1], 228 | }, 229 | }, 230 | ] 231 | 232 | expected = Any[ 233 | # Adjust this guy's shift max 234 | { 235 | "id" => 1, 236 | "name" => "Nikola Tesla", 237 | "min_shift_length" => 2, 238 | "max_shift_length" => 9, 239 | "min_shifts_per_week" => 1, 240 | "max_shifts_per_week" => 6, 241 | "min_hours_per_week" => 2, 242 | "max_hours_per_week" => 20, 243 | "availability" => { 244 | # Annealed 245 | "sunday" => [0, 0, 0, 0, 0, 0, 0, 0], 246 | "monday" => [1, 1, 1, 1, 1, 1, 1, 1], 247 | "tuesday" => [1, 1, 1, 1, 1, 1, 1, 1], 248 | "wednesday" => [1, 1, 1, 1, 1, 1, 1, 1], 249 | "thursday" => [1, 1, 1, 1, 1, 1, 1, 1], 250 | "friday" => [1, 1, 1, 1, 1, 1, 1, 1], 251 | "saturday" => [1, 1, 1, 1, 1, 1, 1, 1], 252 | }, 253 | }, 254 | { # Adjust this guy's shift min and max 255 | "id" => 2, 256 | "name" => "Richard Feynman", 257 | "min_shift_length" => 1, 258 | "max_shift_length" => 9, 259 | "min_shifts_per_week" => 4, 260 | "max_shifts_per_week" => 4, 261 | "min_hours_per_week" => 4, 262 | "max_hours_per_week" => 20, 263 | "availability" => { 264 | "sunday" => [0, 0, 0, 0, 0, 0, 0, 0], 265 | "monday" => [0, 0, 0, 0, 0, 0, 0, 0], 266 | "tuesday" => [0, 0, 0, 0, 0, 0, 0, 0], 267 | "wednesday" => [1, 1, 1, 1, 1, 1, 1, 1], 268 | "thursday" => [1, 1, 1, 1, 1, 1, 1, 1], 269 | "friday" => [1, 1, 1, 1, 1, 1, 1, 1], 270 | "saturday" => [1, 1, 1, 1, 1, 1, 1, 1], 271 | }, 272 | }, 273 | ] 274 | 275 | 276 | actual = downgrade_availability(schedule, workers, organization, Dict(), false) 277 | 278 | @test expected == actual 279 | end 280 | 281 | function test_downgrade_hours() 282 | schedule = { 283 | "demand" => { 284 | "sunday" => [1, 2, 3, 3, 2, 1, 1, 1], 285 | "monday" => [1, 2, 3, 3, 2, 1, 1, 1], 286 | "tuesday" => [1, 2, 3, 3, 2, 1, 1, 1], 287 | "wednesday" => [1, 2, 3, 3, 2, 1, 1, 1], 288 | "thursday" => [1, 2, 3, 3, 2, 1, 1, 1], 289 | "friday" => [1, 2, 3, 3, 2, 1, 1, 1], 290 | "saturday" => [1, 2, 3, 3, 2, 1, 1, 1], 291 | }, 292 | } 293 | 294 | organization = { 295 | "day_week_starts" => "sunday", 296 | } 297 | 298 | workers = Any[ 299 | # Adjust this guy's shift max 300 | { 301 | "id" => 1, 302 | "name" => "Nikola Tesla", 303 | "min_shift_length" => 2, 304 | "max_shift_length" => 3, 305 | "min_shifts_per_week" => 1, 306 | "max_shifts_per_week" => 7, 307 | "min_hours_per_week" => 1, 308 | "max_hours_per_week" => 80, 309 | "availability" => { 310 | "sunday" => [1, 1, 1, 1, 1, 1, 1, 1], 311 | "monday" => [1, 1, 1, 1, 1, 1, 1, 1], 312 | "tuesday" => [1, 1, 1, 1, 1, 1, 1, 1], 313 | "wednesday" => [1, 1, 1, 1, 1, 1, 1, 1], 314 | "thursday" => [1, 1, 1, 1, 1, 1, 1, 1], 315 | "friday" => [1, 1, 1, 1, 1, 1, 1, 1], 316 | "saturday" => [1, 1, 1, 1, 1, 1, 1, 1], 317 | }, 318 | }, 319 | { # Adjust this guy's shift min and max 320 | "id" => 2, 321 | "name" => "Richard Feynman", 322 | "min_shift_length" => 5, 323 | "max_shift_length" => 5, 324 | "min_shifts_per_week" => 1, 325 | "max_shifts_per_week" => 1, 326 | "min_hours_per_week" => 6, 327 | "max_hours_per_week" => 20, 328 | "availability" => { 329 | "sunday" => [0, 0, 0, 0, 0, 0, 0, 0], 330 | "monday" => [0, 0, 0, 0, 0, 0, 0, 0], 331 | "tuesday" => [0, 0, 0, 0, 0, 0, 0, 0], 332 | "wednesday" => [1, 1, 1, 1, 1, 1, 1, 1], 333 | "thursday" => [1, 1, 1, 1, 1, 1, 1, 1], 334 | "friday" => [1, 1, 1, 1, 1, 1, 1, 1], 335 | "saturday" => [1, 1, 1, 1, 1, 1, 1, 1], 336 | }, 337 | }, 338 | ] 339 | 340 | expected = Any[ 341 | # Adjust this guy's shift max 342 | { 343 | "id" => 1, 344 | "name" => "Nikola Tesla", 345 | "min_shift_length" => 2, 346 | "max_shift_length" => 3, 347 | "min_shifts_per_week" => 1, 348 | "max_shifts_per_week" => 7, 349 | "min_hours_per_week" => 2, 350 | "max_hours_per_week" => 21, 351 | "availability" => { 352 | # Annealed 353 | "sunday" => [1, 1, 1, 1, 1, 1, 1, 1], 354 | "monday" => [1, 1, 1, 1, 1, 1, 1, 1], 355 | "tuesday" => [1, 1, 1, 1, 1, 1, 1, 1], 356 | "wednesday" => [1, 1, 1, 1, 1, 1, 1, 1], 357 | "thursday" => [1, 1, 1, 1, 1, 1, 1, 1], 358 | "friday" => [1, 1, 1, 1, 1, 1, 1, 1], 359 | "saturday" => [1, 1, 1, 1, 1, 1, 1, 1], 360 | }, 361 | }, 362 | { # Adjust this guy's shift min and max 363 | "id" => 2, 364 | "name" => "Richard Feynman", 365 | "min_shift_length" => 5, 366 | "max_shift_length" => 5, 367 | "min_shifts_per_week" => 1, 368 | "max_shifts_per_week" => 1, 369 | "min_hours_per_week" => 5, 370 | "max_hours_per_week" => 5, 371 | "availability" => { 372 | "sunday" => [0, 0, 0, 0, 0, 0, 0, 0], 373 | "monday" => [0, 0, 0, 0, 0, 0, 0, 0], 374 | "tuesday" => [0, 0, 0, 0, 0, 0, 0, 0], 375 | "wednesday" => [1, 1, 1, 1, 1, 1, 1, 1], 376 | "thursday" => [1, 1, 1, 1, 1, 1, 1, 1], 377 | "friday" => [1, 1, 1, 1, 1, 1, 1, 1], 378 | "saturday" => [1, 1, 1, 1, 1, 1, 1, 1], 379 | }, 380 | }, 381 | ] 382 | 383 | 384 | actual = downgrade_availability(schedule, workers, organization, Dict(), false) 385 | 386 | @test expected == actual 387 | end 388 | 389 | function test_downgrade_hours_and_shifts() 390 | schedule = { 391 | "demand" => { 392 | "sunday" => [1, 2, 3, 3, 2, 1, 1, 1], 393 | "monday" => [1, 2, 3, 3, 2, 1, 1, 1], 394 | "tuesday" => [1, 2, 3, 3, 2, 1, 1, 1], 395 | "wednesday" => [1, 2, 3, 3, 2, 1, 1, 1], 396 | "thursday" => [1, 2, 3, 3, 2, 1, 1, 1], 397 | "friday" => [1, 2, 3, 3, 2, 1, 1, 1], 398 | "saturday" => [1, 2, 3, 3, 2, 1, 1, 1], 399 | }, 400 | } 401 | 402 | organization = { 403 | "day_week_starts" => "sunday", 404 | } 405 | 406 | workers = Any[ 407 | { 408 | "id" => 1, 409 | "name" => "Nikola Tesla", 410 | "min_shift_length" => 2, 411 | "max_shift_length" => 3, 412 | "min_shifts_per_week" => 1, 413 | "max_shifts_per_week" => 7, 414 | "min_hours_per_week" => 1, 415 | "max_hours_per_week" => 80, 416 | "availability" => { 417 | "sunday" => [0, 0, 0, 0, 0, 0, 0, 0], 418 | "monday" => [0, 0, 0, 0, 0, 0, 0, 0], 419 | "tuesday" => [0, 0, 0, 0, 0, 0, 0, 0], 420 | "wednesday" => [1, 1, 1, 1, 1, 1, 1, 1], 421 | "thursday" => [1, 1, 1, 1, 1, 1, 1, 1], 422 | "friday" => [1, 1, 1, 1, 1, 1, 1, 1], 423 | "saturday" => [1, 1, 1, 1, 1, 1, 1, 1], 424 | }, 425 | }, 426 | { 427 | "id" => 2, 428 | "name" => "Richard Feynman", 429 | "min_shift_length" => 4, 430 | "max_shift_length" => 5, 431 | "min_shifts_per_week" => 1, 432 | "max_shifts_per_week" => 2, 433 | "min_hours_per_week" => 3, 434 | "max_hours_per_week" => 20, 435 | "availability" => { 436 | "sunday" => [0, 0, 0, 0, 0, 0, 0, 0], 437 | "monday" => [0, 0, 0, 0, 0, 0, 0, 0], 438 | "tuesday" => [0, 0, 0, 0, 0, 0, 0, 0], 439 | "wednesday" => [0, 0, 0, 0, 0, 0, 0, 0], 440 | "thursday" => [0, 0, 0, 0, 0, 0, 0, 0], 441 | "friday" => [0, 0, 0, 0, 0, 0, 0, 0], 442 | "saturday" => [1, 1, 1, 1, 1, 1, 1, 1], 443 | }, 444 | }, 445 | { 446 | "id" => 3, 447 | "name" => "That weird guy who smells like onions", 448 | "min_shift_length" => 5, 449 | "max_shift_length" => 8, 450 | "min_shifts_per_week" => 1, 451 | "max_shifts_per_week" => 4, 452 | "min_hours_per_week" => 16, 453 | "max_hours_per_week" => 32, 454 | "availability" => { 455 | "sunday" => [1, 1, 1, 1, 1, 1, 1, 1], 456 | "monday" => [1, 1, 1, 1, 1, 1, 1, 1], 457 | "tuesday" => [1, 1, 1, 1, 1, 1, 1, 1], 458 | "wednesday" => [1, 1, 1, 1, 1, 1, 1, 1], 459 | "thursday" => [1, 1, 1, 1, 1, 1, 1, 1], 460 | "friday" => [1, 1, 1, 1, 1, 1, 1, 1], 461 | "saturday" => [1, 1, 1, 1, 1, 1, 1, 1], 462 | }, 463 | }, 464 | ] 465 | 466 | expected = Any[ 467 | # Adjust this guy's shift max 468 | { 469 | "id" => 1, 470 | "name" => "Nikola Tesla", 471 | "min_shift_length" => 2, 472 | "max_shift_length" => 3, 473 | "min_shifts_per_week" => 1, 474 | "max_shifts_per_week" => 4, 475 | "min_hours_per_week" => 2, 476 | "max_hours_per_week" => 12, 477 | "availability" => { 478 | "sunday" => [0, 0, 0, 0, 0, 0, 0, 0], 479 | "monday" => [0, 0, 0, 0, 0, 0, 0, 0], 480 | "tuesday" => [0, 0, 0, 0, 0, 0, 0, 0], 481 | "wednesday" => [1, 1, 1, 1, 1, 1, 1, 1], 482 | "thursday" => [1, 1, 1, 1, 1, 1, 1, 1], 483 | "friday" => [1, 1, 1, 1, 1, 1, 1, 1], 484 | "saturday" => [1, 1, 1, 1, 1, 1, 1, 1], 485 | }, 486 | }, 487 | { # Adjust this guy's shift min and max 488 | "id" => 2, 489 | "name" => "Richard Feynman", 490 | "min_shift_length" => 4, 491 | "max_shift_length" => 5, 492 | "min_shifts_per_week" => 1, 493 | "max_shifts_per_week" => 1, 494 | "min_hours_per_week" => 4, 495 | "max_hours_per_week" => 5, 496 | "availability" => { 497 | "sunday" => [0, 0, 0, 0, 0, 0, 0, 0], 498 | "monday" => [0, 0, 0, 0, 0, 0, 0, 0], 499 | "tuesday" => [0, 0, 0, 0, 0, 0, 0, 0], 500 | "wednesday" => [0, 0, 0, 0, 0, 0, 0, 0], 501 | "thursday" => [0, 0, 0, 0, 0, 0, 0, 0], 502 | "friday" => [0, 0, 0, 0, 0, 0, 0, 0], 503 | "saturday" => [1, 1, 1, 1, 1, 1, 1, 1], 504 | }, 505 | }, 506 | { 507 | "id" => 3, 508 | "name" => "That weird guy who smells like onions", 509 | "min_shift_length" => 5, 510 | "max_shift_length" => 8, 511 | "min_shifts_per_week" => 2, 512 | "max_shifts_per_week" => 4, 513 | "min_hours_per_week" => 16, 514 | "max_hours_per_week" => 32, 515 | "availability" => { 516 | "sunday" => [1, 1, 1, 1, 1, 1, 1, 1], 517 | "monday" => [1, 1, 1, 1, 1, 1, 1, 1], 518 | "tuesday" => [1, 1, 1, 1, 1, 1, 1, 1], 519 | "wednesday" => [1, 1, 1, 1, 1, 1, 1, 1], 520 | "thursday" => [1, 1, 1, 1, 1, 1, 1, 1], 521 | "friday" => [1, 1, 1, 1, 1, 1, 1, 1], 522 | "saturday" => [1, 1, 1, 1, 1, 1, 1, 1], 523 | }, 524 | }, 525 | ] 526 | 527 | 528 | actual = downgrade_availability(schedule, workers, organization, Dict(), false) 529 | @test expected == actual 530 | end 531 | 532 | function test_only_available_when_business_closed_downgrade() 533 | # Basically same as last test, but some unavailability comes from 534 | # the business being closed and the worker not having enough hours 535 | # on the day to have it count. 536 | 537 | schedule = { 538 | "demand" => { 539 | "sunday" => [0, 0, 0, 0, 2, 1, 1, 1], 540 | "monday" => [1, 2, 3, 3, 2, 1, 1, 1], 541 | "tuesday" => [1, 2, 3, 3, 2, 1, 1, 1], 542 | "wednesday" => [1, 2, 3, 3, 2, 1, 1, 1], 543 | "thursday" => [1, 2, 3, 3, 2, 1, 1, 1], 544 | "friday" => [1, 2, 3, 3, 2, 1, 1, 1], 545 | "saturday" => [1, 2, 3, 3, 2, 1, 1, 1], 546 | }, 547 | } 548 | 549 | organization = { 550 | "day_week_starts" => "sunday", 551 | } 552 | 553 | workers = Any[ 554 | { 555 | "id" => 1, 556 | "name" => "Nikola Tesla", 557 | "min_shift_length" => 2, 558 | "max_shift_length" => 3, 559 | "min_shifts_per_week" => 1, 560 | "max_shifts_per_week" => 7, 561 | "min_hours_per_week" => 1, 562 | "max_hours_per_week" => 80, 563 | "availability" => { 564 | "sunday" => [1, 1, 1, 1, 0, 0, 0, 0], 565 | "monday" => [0, 0, 0, 0, 0, 0, 0, 0], 566 | "tuesday" => [0, 0, 0, 0, 0, 0, 0, 0], 567 | "wednesday" => [1, 1, 1, 1, 1, 1, 1, 1], 568 | "thursday" => [1, 1, 1, 1, 1, 1, 1, 1], 569 | "friday" => [1, 1, 1, 1, 1, 1, 1, 1], 570 | "saturday" => [1, 1, 1, 1, 1, 1, 1, 1], 571 | }, 572 | }, 573 | { 574 | "id" => 2, 575 | "name" => "Richard Feynman", 576 | "min_shift_length" => 4, 577 | "max_shift_length" => 5, 578 | "min_shifts_per_week" => 1, 579 | "max_shifts_per_week" => 2, 580 | "min_hours_per_week" => 3, 581 | "max_hours_per_week" => 20, 582 | "availability" => { 583 | "sunday" => [1, 1, 1, 0, 0, 0, 0, 0], 584 | "monday" => [0, 0, 0, 0, 0, 0, 0, 0], 585 | "tuesday" => [0, 0, 0, 0, 0, 0, 0, 0], 586 | "wednesday" => [0, 0, 0, 0, 0, 0, 0, 0], 587 | "thursday" => [0, 0, 0, 0, 0, 0, 0, 0], 588 | "friday" => [0, 0, 0, 0, 0, 0, 0, 0], 589 | "saturday" => [1, 1, 1, 1, 1, 1, 1, 1], 590 | }, 591 | }, 592 | ] 593 | 594 | expected = Any[ 595 | # Adjust this guy's shift max 596 | { 597 | "id" => 1, 598 | "name" => "Nikola Tesla", 599 | "min_shift_length" => 2, 600 | "max_shift_length" => 3, 601 | "min_shifts_per_week" => 1, 602 | "max_shifts_per_week" => 4, 603 | "min_hours_per_week" => 2, 604 | "max_hours_per_week" => 12, 605 | "availability" => { 606 | "sunday" => [0, 0, 0, 0, 0, 0, 0, 0], 607 | "monday" => [0, 0, 0, 0, 0, 0, 0, 0], 608 | "tuesday" => [0, 0, 0, 0, 0, 0, 0, 0], 609 | "wednesday" => [1, 1, 1, 1, 1, 1, 1, 1], 610 | "thursday" => [1, 1, 1, 1, 1, 1, 1, 1], 611 | "friday" => [1, 1, 1, 1, 1, 1, 1, 1], 612 | "saturday" => [1, 1, 1, 1, 1, 1, 1, 1], 613 | }, 614 | }, 615 | { # Adjust this guy's shift min and max 616 | "id" => 2, 617 | "name" => "Richard Feynman", 618 | "min_shift_length" => 4, 619 | "max_shift_length" => 5, 620 | "min_shifts_per_week" => 1, 621 | "max_shifts_per_week" => 1, 622 | "min_hours_per_week" => 4, 623 | "max_hours_per_week" => 5, 624 | "availability" => { 625 | "sunday" => [0, 0, 0, 0, 0, 0, 0, 0], 626 | "monday" => [0, 0, 0, 0, 0, 0, 0, 0], 627 | "tuesday" => [0, 0, 0, 0, 0, 0, 0, 0], 628 | "wednesday" => [0, 0, 0, 0, 0, 0, 0, 0], 629 | "thursday" => [0, 0, 0, 0, 0, 0, 0, 0], 630 | "friday" => [0, 0, 0, 0, 0, 0, 0, 0], 631 | "saturday" => [1, 1, 1, 1, 1, 1, 1, 1], 632 | }, 633 | }, 634 | ] 635 | 636 | 637 | actual = downgrade_availability(schedule, workers, organization, Dict(), false) 638 | @test expected == actual 639 | end 640 | -------------------------------------------------------------------------------- /StaffJoy/week.jl: -------------------------------------------------------------------------------- 1 | function schedule(employees, env) 2 | # Wrap the scheduling in windowing! 3 | start_index, end_index, time_delta = get_window(env["coverage"]) 4 | 5 | # Apply the window 6 | env["time_between_coverage"] += time_delta 7 | if "cycle_length" in keys(env) 8 | env["cycle_length"] -= time_delta 9 | end 10 | if "no_shifts_after" in keys(env) 11 | env["no_shifts_after"] -= time_delta 12 | end 13 | env["coverage"] = apply_window(env["coverage"], start_index, end_index) 14 | # todo - do we have a "time since last shift" at start of week? 15 | for e in keys(employees) 16 | employees[e]["availability"] = apply_window(employees[e]["availability"], start_index, end_index) 17 | end 18 | Logging.info( "Windowing removed $time_delta hours per day (start index $start_index / end index $end_index)") 19 | 20 | ok, shifts = run_schedule(employees, env) 21 | if !ok 22 | Logging.info( "Scheduling not ok at windower") 23 | return ok, {} 24 | end 25 | 26 | # Exit the window and return 27 | return ok, remove_window(shifts, start_index) 28 | end 29 | 30 | function run_schedule(employees, env) 31 | if length(employees) == 0 32 | Logging.info( "Switching to unassigned shifts mode") 33 | ok, shifts = run_schedule_unassigned(env) 34 | else 35 | ok, shifts = run_schedule_standard(employees, env) 36 | end 37 | return ok, shifts 38 | end 39 | 40 | function run_schedule_standard(employees, env) 41 | attempt = 1 42 | employees = meet_base_coverage(env, employees) 43 | 44 | @label schedule_start 45 | 46 | ok, employees, env = build_week(employees, env) 47 | if !ok 48 | Logging.info( "Build failed") 49 | return false, {} 50 | end 51 | 52 | ok, message = validate_input(employees, env) 53 | if !ok 54 | Logging.info( message) 55 | return false, {} 56 | end 57 | 58 | # Check days per week is large enough for consec 59 | if size(env["coverage"], 1) > 2 60 | # Schedule consec first, and do non-consec as a last resort 61 | consecutive = true 62 | ok, weekly_schedule_consecutive = schedule_week(employees, env, consecutive) 63 | if ok 64 | Logging.info( "Consecutive schedule passed.") 65 | return true, weekly_schedule_consecutive 66 | end 67 | Logging.info( "Consecutive schedule validation failed. Trying inconsecutive.") 68 | end 69 | 70 | consecutive = false 71 | ok, weekly_schedule = schedule_week(employees, env, consecutive) 72 | if ok 73 | return true, weekly_schedule 74 | end 75 | 76 | # Otherwise - make an unassigned shift and restart 77 | name, val = generate_unassigned_shift(env) 78 | Logging.info( "Unable to meet coverage. Adding an unassigned shift and repeating. (end of attempt $attempt)") 79 | employees[name] = val 80 | attempt += 1 81 | @goto schedule_start 82 | end 83 | 84 | function run_schedule_unassigned(env) 85 | total_hours = sum(sum(env["coverage"])) 86 | Logging.info( "Coverage total hours: $total_hours") 87 | if total_hours > BIFURCATE_THRESHOLD 88 | # Generate subproblems 89 | Logging.info( "Split env into sub problems") 90 | ok, shifts = bifurcate_unassigned_scheduler(env) 91 | if !ok 92 | Logging.err("Bifurcatin failed in unassigned shift") 93 | end 94 | return ok, shifts 95 | end 96 | 97 | # Schedule day by day 98 | week_length = size(env["coverage"], 1) 99 | week_employees = Dict() 100 | shifts = Dict() 101 | for day in 1:week_length 102 | Logging.info( "Starting day scheduler day $day of $week_length") 103 | # Build a day env 104 | day_env = deepcopy(env) 105 | day_env["coverage"] = env["coverage"][day] 106 | 107 | # Generate lots of shifts 108 | day_employees = meet_unassigned_base_coverage(env, day) 109 | 110 | 111 | ok = false 112 | attempt = 1 113 | # Define day_shifts up here due to scoping 114 | day_shifts = Dict() 115 | while !ok 116 | # Change week employees to day employees 117 | day_employees_filtered = Dict() 118 | for e in keys(day_employees) 119 | d = deepcopy(day_employees[e]) 120 | d["availability"] = day_employees[e]["availability"][day] 121 | d["min"] = d["shift_time_min"] 122 | d["max"] = d["shift_time_max"] 123 | day_employees_filtered[e] = d 124 | end 125 | 126 | # Run day schedule 127 | ok, day_shifts = schedule_day(day_employees_filtered, day_env) 128 | 129 | if !ok 130 | Logging.info( "Unable to meet coverage. Adding an unassigned shift and repeating. (end of attempt $attempt on day $day)") 131 | name, val = generate_unassigned_shift(env) 132 | day_employees[name] = val 133 | attempt += 1 134 | end 135 | end 136 | 137 | # push for testing later 138 | for k in keys(day_employees) 139 | week_employees[k] = day_employees[k] 140 | end 141 | 142 | # Now we have to do post-processing to adjust the day correctly 143 | for name in keys(day_shifts) 144 | shift = day_shifts[name] 145 | # Being really careful 146 | shift["day"] = day 147 | if !(name in keys(shifts)) 148 | shifts[name] = Any[] 149 | end 150 | push!(shifts[name], shift) 151 | end 152 | end 153 | 154 | ok, message= validate_schedule(week_employees, env, shifts) 155 | if !ok 156 | Logging.err("Unassigned shift schedule invalid after post-processing - $message") 157 | end 158 | return ok, shifts 159 | end 160 | 161 | function schedule_week(employees, env, consecutive) 162 | 163 | # Setup 164 | valid_schedules = Any[] 165 | prior_lift = false 166 | lift_ceiling = false 167 | weekly_schedule = Dict{Any,Any} 168 | start_day = 0 169 | start_time = time() 170 | perfect_optimality = week_sum_coverage(env) 171 | 172 | # Start the loop, captain! 173 | while (prior_lift == false || (prior_lift * LYFT_THROTTLE > MIN_LYFT)) 174 | if (time() > start_time + ITERATION_TIMEOUT) && (length(valid_schedules) == 0) 175 | Logging.info("Iteration timeout hit") 176 | break 177 | end 178 | 179 | if (time() > start_time + SEARCH_TIMEOUT) && length(valid_schedules) > 0 180 | Logging.info("Search timeout hit") 181 | break 182 | end 183 | 184 | if prior_lift != false 185 | lift_ceiling = prior_lift * LYFT_THROTTLE 186 | end 187 | 188 | ok, employees, prior_lift = assign_employees_to_days(employees, env, consecutive, lift_ceiling) 189 | if !ok 190 | Logging.info( "consecutive $consecutive day assignment failed") 191 | break 192 | end 193 | 194 | # Set up parallel calculations 195 | calculations = Any[] 196 | 197 | # Only run one scheduler if it's a single day. 198 | if size(env["coverage"], 1) > 1 199 | methods = ["balanced", "greedy"] 200 | else 201 | methods = ["balanced"] 202 | end 203 | 204 | for start_day=1:size(env["coverage"], 1) 205 | for method in methods 206 | push!(calculations, { 207 | "start_day" => start_day, 208 | "method" => method, 209 | "employees" => employees, 210 | "env" => env, 211 | }) 212 | end 213 | end 214 | 215 | results = pmap(schedule_by_day, calculations) 216 | lift_schedules = map(return_schedules, filter(valid_schedules_net, results)) 217 | num = length(lift_schedules) 218 | Logging.info( "Found $num schedules this lift") 219 | for valid_schedule=lift_schedules 220 | hours = week_hours_scheduled(valid_schedule) 221 | if hours == perfect_optimality 222 | return true, valid_schedule 223 | end 224 | push!(valid_schedules, valid_schedule) 225 | end 226 | end 227 | 228 | if length(valid_schedules) == 0 229 | return false, {} 230 | end 231 | 232 | best_hours = 0 233 | best_schedule = None 234 | for test_schedule=valid_schedules 235 | test_hours = week_hours_scheduled(test_schedule) 236 | if (test_hours < best_hours) || (best_schedule == None) 237 | best_hours = test_hours 238 | best_schedule = test_schedule 239 | end 240 | end 241 | 242 | if best_schedule == None 243 | num = length(valid_schedules) 244 | Logging.info( "Found $num schedules") 245 | return false, {} 246 | end 247 | elapsed = (time() - start_time) / 60 248 | num_schedules = length(valid_schedules) 249 | hours = week_hours_scheduled(best_schedule) 250 | overage = (hours - perfect_optimality) / perfect_optimality 251 | Logging.info( "Full time took $elapsed minutes and ended at lift $prior_lift. Found $num_schedules valid schedules. Best overage $overage") 252 | return true, best_schedule 253 | 254 | end 255 | 256 | function build_week(employees, env) 257 | # First check for some required shit in the environment 258 | if !("shift_time_min" in keys(env)) 259 | # turn employees into a message variable 260 | return false, "shift_time_min not in environment", {} 261 | end 262 | 263 | if !("shift_time_max" in keys(env)) 264 | Logging.info( "shift_time_max not in environment") 265 | return false, {}, {} 266 | end 267 | 268 | 269 | days_per_week = size(env["coverage"], 1) 270 | # Pick number of shifts per employee 271 | for e in keys(employees) 272 | 273 | # This has to be run now due to later function dependencies 274 | if (size(employees[e]["availability"], 1) != days_per_week) 275 | Logging.info( string("Employee ", e, " availability wrong number days per week")) 276 | return false, {}, {} 277 | end 278 | 279 | # set employee to unavailable when coverage is 0 280 | for day in 1:days_per_week 281 | for t in size(env["coverage"][day]) 282 | if env["coverage"][day][t] == 0 283 | employees[e]["availability"][day][t] = 0 284 | end 285 | end 286 | end 287 | 288 | # shift time 289 | if !("shift_time_min" in keys(employees[e])) 290 | # inherit from env if not defined 291 | employees[e]["shift_time_min"] = env["shift_time_min"] 292 | end 293 | 294 | if !("shift_time_max" in keys(employees[e])) 295 | employees[e]["shift_time_max"] = env["shift_time_max"] 296 | end 297 | 298 | if ( 299 | "shift_count_min" in keys(employees[e]) 300 | && "shift_count_max" in keys(employees[e]) 301 | ) 302 | continue 303 | end 304 | 305 | # calculate based on min/max 306 | if "shift_count" in keys(employees[e]) 307 | employees[e]["shift_count_min"] = employees[e]["shift_count"] 308 | employees[e]["shift_count_max"] = employees[e]["shift_count"] 309 | continue 310 | end 311 | 312 | if !("shift_count_min" in keys(employees[e])) 313 | # calculate from limits 314 | employees[e]["shift_count_min"] = int(ceil(employees[e]["hours_min"] / employees[e]["shift_time_max"])) 315 | end 316 | 317 | if !("shift_count_max" in keys(employees[e])) 318 | max = int(floor(employees[e]["hours_max"] / employees[e]["shift_time_min"])) 319 | # can't exceed days per week 320 | if max > days_per_week 321 | max = days_per_week 322 | end 323 | employees[e]["shift_count_max"] = max 324 | end 325 | 326 | # Set ceiling of shift_count_max at availability 327 | days_available = days_available_per_week(employees[e]) 328 | if employees[e]["shift_count_max"] > days_available 329 | employees[e]["shift_count_max"] = days_available 330 | end 331 | 332 | end 333 | 334 | # Anneal availability - get rid of availability that isn't feasible 335 | for e in keys(employees) 336 | employees[e]["availability"] = anneal_availability(employees[e]["availability"], employees[e]["shift_time_min"]) 337 | end 338 | 339 | # day availability - num hours using longest_shift 340 | for e in keys(employees) 341 | longest_per_day = Int[] 342 | for day in 1:days_per_week 343 | push!(longest_per_day, get_longest_availability(employees[e], day)) 344 | end 345 | employees[e]["longest_availability"] = longest_per_day 346 | end 347 | 348 | 349 | return true, employees, env 350 | end 351 | 352 | function validate_input(employees, env) 353 | # first build an array that is the length of each day of the week 354 | day_length = Int[] 355 | days_per_week = size(env["coverage"], 1) 356 | 357 | for day in 1:days_per_week 358 | push!(day_length, length(env["coverage"][day])) 359 | end 360 | 361 | # Days must be the same length 362 | overage = 0 363 | for day in 2:days_per_week 364 | if day_length[day] != day_length[1] 365 | return false, "ERROR: excessive day length mismatch" 366 | end 367 | end 368 | 369 | # Build the coverage count 370 | coverage_count_per_hour = Any[] 371 | for day in 1:size(day_length, 1) 372 | push!(coverage_count_per_hour, zeros(Int, day_length[day])) 373 | end 374 | 375 | # sum this from employee longest_availability variable 376 | coverage_count_per_day = zeros(Int, days_per_week) 377 | 378 | # Loop through employees 379 | # do shit, including adding to coverage 380 | for e in keys(employees) 381 | for t in 1:days_per_week 382 | if size(employees[e]["availability"][t], 1) != day_length[t] 383 | return false, string("Employee ", e, " availability for day ", t, " wrong number of hours") 384 | end 385 | # 1) Push hourly availability to per-hour coverage 386 | for h in day_length[t] 387 | coverage_count_per_hour[t][h] += employees[e]["availability"][t][h] 388 | end 389 | # Check their max availability per day 390 | coverage_count_per_day[t] += employees[e]["longest_availability"][t] 391 | end 392 | # Make copy of longest_availability then sort it; largest first 393 | avail = sort(employees[e]["longest_availability"], rev=true) 394 | # check that the sum of largest "longest avail" >= hours_min 395 | avail_sum_max = 0 396 | for i in 1:employees[e]["shift_count_max"] 397 | # bounds check - threw an error before 398 | if i <= length(avail) 399 | avail_sum_max += avail[i] 400 | end 401 | end 402 | 403 | if avail_sum_max < employees[e]["hours_min"] 404 | # check that sum of longest_availability > hours_min 405 | return false, string("Employee ", e, " not available for min_hours") 406 | end 407 | end 408 | 409 | for e in keys(employees) 410 | # Shift count validations 411 | shift_count_min = employees[e]["shift_count_min"] 412 | shift_count_max = employees[e]["shift_count_max"] 413 | shift_time_min = employees[e]["shift_time_min"] # inherited from env 414 | shift_time_max = employees[e]["shift_time_max"] 415 | hours_min = employees[e]["hours_min"] 416 | hours_max = employees[e]["hours_max"] 417 | days_available = days_available_per_week(employees[e]) 418 | 419 | if shift_count_min > days_available 420 | return false, "Employee $e has fewer days available than minimum shift count" 421 | end 422 | 423 | if shift_count_max > days_per_week 424 | return false, "shift_count_max exceeds days per week" 425 | end 426 | 427 | 428 | if shift_count_min > shift_count_max 429 | return false, "shift count min is larger than shift count max!" 430 | end 431 | 432 | if shift_count_min * shift_time_max < hours_min 433 | return false, "Shift count min is too low for employee $e" 434 | end 435 | 436 | if shift_count_max * shift_time_min > hours_max 437 | return false, "Shift count max is too high for employee $e" 438 | end 439 | end 440 | 441 | return true, "" 442 | end 443 | 444 | function assign_employees_to_days(employees, env, consecutive=false, lift_ceiling=false) 445 | #= 446 | Consecutive days off works by looking at days_per_week, and if an employee 447 | is scheduled for <= (days_per_week-2) shifts, then at least two of their 448 | days off must be consecutive. 449 | =# 450 | days_per_week = size(env["coverage"], 1) 451 | number_of_employees = length(employees) 452 | decision_index_max = days_per_week * number_of_employees 453 | day_length = Int[] 454 | 455 | for day in 1:days_per_week 456 | push!(day_length, length(env["coverage"][day])) 457 | end 458 | 459 | if GUROBI 460 | m = Model( 461 | solver=GurobiSolver( 462 | LogToConsole=0, 463 | TimeLimit=CALCULATION_TIMEOUT, 464 | OutputFlag=0, 465 | ) 466 | ) 467 | else 468 | m = Model( 469 | solver=CbcSolver( 470 | threads=Base.CPU_CORES, 471 | ) 472 | ) 473 | end 474 | 475 | 476 | 477 | # Decision Variables 478 | @defVar(m, decision_variable[1:decision_index_max], Bin) 479 | 480 | if consecutive 481 | @defVar(m, decision_consecutive[1:decision_index_max], Bin) 482 | end 483 | 484 | #= 485 | Lyft tries to evenly allocate extra resources between days. This way, 486 | if you have excess capacity, it is spread evenly over days and thus 487 | maximizes likelihood of feasibility. 488 | Ignoring lift may mean that one day gets all the excess capacity. This sucks. 489 | =# 490 | @defVar(m, lift >= 1) 491 | 492 | decision_index_to_employee = String[] 493 | decision_index_to_day = Int[] 494 | decision_index_hours = Int[] 495 | for e in keys(employees) 496 | for t in 1:days_per_week 497 | push!(decision_index_to_employee, e) 498 | push!(decision_index_to_day, t) 499 | push!(decision_index_hours, employees[e]["longest_availability"][t]) 500 | end 501 | end 502 | 503 | # Now we do per-employee constraint 504 | # Note: decision var 1st e.g. use x*3 instead of 3*x 505 | for e in keys(employees) 506 | # days assigned = # shifts 507 | # Break into to sections due to Gurobi range issue in Julia 508 | @addConstraint(m, 509 | employees[e]["shift_count_min"] <= sum{decision_variable[i], i=1:decision_index_max; decision_index_to_employee[i] == e} 510 | ) 511 | 512 | @addConstraint(m, 513 | sum{decision_variable[i], i=1:decision_index_max; decision_index_to_employee[i] == e} <= employees[e]["shift_count_max"] 514 | ) 515 | 516 | # assigned days * longest availability > min week hours 517 | @addConstraint(m, 518 | sum{decision_index_hours[i] * decision_variable[i], i=1:decision_index_max; decision_index_to_employee[i] == e} >= employees[e]["hours_min"] 519 | ) 520 | 521 | if consecutive 522 | if employees[e]["shift_count_max"] < (days_per_week - 1) 523 | @addConstraint(m, 524 | sum{decision_consecutive[i], i=1:decision_index_max; decision_index_to_employee[i] == e } >= 1 525 | ) 526 | end 527 | end 528 | end 529 | 530 | for i in 1:decision_index_max 531 | # If an employee has no availability - don't schedule them! 532 | if decision_index_hours[i] == 0 533 | @addConstraint(m, decision_variable[i] == 0) 534 | end 535 | 536 | if consecutive 537 | if decision_index_to_day[i] == 1 538 | # See if employee worked day before this week 539 | if ("worked_day_preceding_week" in keys(employees[decision_index_to_employee[i]])) && (employees[decision_index_to_employee[i]]["worked_day_preceding_week"] == false) 540 | @addConstraint(m, decision_consecutive[i] + decision_variable[i] == 1) 541 | else 542 | # throw away the first index - it defaults to zero 543 | @addConstraint(m, decision_consecutive[i] == 0) 544 | end 545 | else # day > 1 546 | # Goal: decision_consecutive[i] = 1 IF AND ONLY IF 547 | # decision_variable[i]=0 and decision_variable[i-1]=0 548 | @addConstraint(m, (decision_variable[i] + decision_variable[i-1]) >= 1 - decision_consecutive[i]) 549 | @addConstraint(m, (2 - decision_variable[i] - decision_variable[i-1]) >= 2*decision_consecutive[i]) 550 | end 551 | 552 | end 553 | end 554 | 555 | for d in 1:days_per_week 556 | # sum of day coverage is sufficient based on longest avail. 557 | @addConstraint(m, 558 | sum{decision_index_hours[i] * decision_variable[i], i=1:decision_index_max; decision_index_to_day[i] == d} >= sum(env["coverage"][d])*lift 559 | ) 560 | 561 | # ensure hourly coverage 562 | for t in 1:day_length[d] 563 | @addConstraint(m, 564 | sum{employees[decision_index_to_employee[i]]["availability"][d][t] * decision_variable[i], i=1:decision_index_max; decision_index_to_day[i] == d} >= env["coverage"][d][t] 565 | ) 566 | end 567 | end 568 | 569 | # For looping if infeasible models 570 | if lift_ceiling != false 571 | @addConstraint(m, lift <= lift_ceiling) 572 | end 573 | 574 | # Objective is to maximize that people get assigned to days 575 | # when they are free 576 | @setObjective(m, Max, lift) 577 | 578 | status = solve(m) 579 | if status == :Optimal 580 | i = 0 581 | for e in keys(employees) 582 | assigned = Int[] 583 | for t in 1:days_per_week 584 | i += 1 585 | push!(assigned, round(getValue(decision_variable[i]))) 586 | end 587 | employees[e]["days_assigned"] = assigned 588 | end 589 | lift_value = getValue(lift) 590 | if days_per_week == 1 591 | # override. Weird bug, but we can have different lifts per day?? 592 | lift_value = 1 593 | end 594 | Logging.info( "Lyft: ", lift_value) 595 | gc() 596 | return true, employees, lift_value 597 | else 598 | Logging.info( "Infeasible") 599 | gc() 600 | return false, {}, 0 601 | end 602 | # ok 603 | gc() 604 | return true, schedule 605 | end 606 | 607 | function validate_schedule(employees, env, weekly_schedule) 608 | days_per_week = size(env["coverage"], 1) 609 | 610 | day_length = Int[] 611 | days_per_week = size(env["coverage"], 1) 612 | 613 | for day in 1:days_per_week 614 | push!(day_length, length(env["coverage"][day])) 615 | end 616 | 617 | 618 | sum_hours = 0 619 | 620 | sum_coverage_by_hour = Any[] 621 | for day in 1:days_per_week 622 | push!(sum_coverage_by_hour, zeros(Int, day_length[day])) 623 | end 624 | 625 | 626 | for e in keys(weekly_schedule) 627 | num_shifts = length(weekly_schedule[e]) 628 | 629 | # employees have shift_count within bounds 630 | if num_shifts < employees[e]["shift_count_min"] 631 | return false, "Employee $e has too few shifts" 632 | end 633 | 634 | if num_shifts > employees[e]["shift_count_max"] 635 | return false, "Employee $e has too many shifts" 636 | end 637 | 638 | employee_sum_hours = 0 639 | 640 | last_shift = null 641 | for i in 1:num_shifts 642 | shift_day = weekly_schedule[e][i]["day"] 643 | shift_start = weekly_schedule[e][i]["start"] 644 | shift_length = weekly_schedule[e][i]["length"] 645 | 646 | # Sum hours 647 | sum_hours += shift_length 648 | employee_sum_hours += shift_length 649 | 650 | # Coverage 651 | for t in shift_start:(shift_start + shift_length - 1) 652 | sum_coverage_by_hour[shift_day][t] += 1 653 | end 654 | 655 | # employees have shift lengths within bounds 656 | if shift_length < employees[e]["shift_time_min"] 657 | return false, "Employee $e shift $i length too short on Day $shift_day - $shift_length" 658 | end 659 | 660 | if shift_length > employees[e]["shift_time_max"] 661 | return false, "Employee $e shift $i length too long on Day $shift_day - $shift_length" 662 | end 663 | 664 | # INTERSHIFT 665 | if shift_day > 1 # check for intershift with last shift 666 | # find shift for preceding day - may not be in order 667 | for j in 1:num_shifts 668 | # Intershift is not supposed to cover more than one day 669 | last_shift_day = shift_day - 1 670 | if weekly_schedule[e][j]["day"] == last_shift_day 671 | last_shift_start = weekly_schedule[e][j]["start"] 672 | last_shift_length = weekly_schedule[e][j]["length"] 673 | last_shift_end = last_shift_start + last_shift_length - 1 674 | hours_left_in_day = length(env["coverage"][last_shift_day]) - last_shift_end 675 | 676 | if "intershift" in keys(employees[e]) 677 | intershift = employees[e]["intershift"] 678 | else 679 | intershift = env["intershift"] 680 | end 681 | 682 | overflow = intershift - hours_left_in_day - env["time_between_coverage"] 683 | if overflow > shift_start 684 | return false, "Not enough time before day $shift_day (overflow $overflow / shift start $shift_start)" 685 | end 686 | end 687 | end 688 | end 689 | end 690 | 691 | # employees have total hours within range 692 | if employee_sum_hours < employees[e]["hours_min"] 693 | return false, "Employee $e works too few hours in the week" 694 | end 695 | 696 | if employee_sum_hours > employees[e]["hours_max"] 697 | return false, "Employee $e works too many hours in the week" 698 | end 699 | 700 | end 701 | sum_coverage = sum(sum(env["coverage"])) 702 | 703 | # Hour by hour coverage 704 | for day in 1:days_per_week 705 | for hour in 1:day_length[day] 706 | if env["coverage"][day][hour] > sum_coverage_by_hour[day][hour] 707 | return false, "Insufficient employees working on day $day at hour $hour" 708 | end 709 | end 710 | end 711 | 712 | return true, "balling" 713 | end 714 | 715 | function valid_schedules_net(schedule) 716 | # For use with schedule_by_day in parallel mode 717 | return schedule["success"] 718 | end 719 | 720 | function return_schedules(schedule) 721 | # For use with schedule_by_day in parallel mode 722 | return schedule["schedule"] 723 | end 724 | --------------------------------------------------------------------------------