From d94e2353b0cddd8b89486d208579d798604ebbe0 Mon Sep 17 00:00:00 2001 From: Kendrick Chan Date: Thu, 11 Jun 2020 01:15:58 +0800 Subject: [PATCH] Add schedule solver script and run it inside analytics controller #409 --- composer.json | 1 + composer.lock | 51 ++++++- src/Controller/AnalyticsController.php | 143 +++++++++++++++++- symfony.lock | 3 + templates/analytics/forecast_submit.html.twig | 84 ++++++++++ utils/schedule_solver/solver.py | 118 +++++++++++++++ 6 files changed, 398 insertions(+), 2 deletions(-) create mode 100644 utils/schedule_solver/solver.py diff --git a/composer.json b/composer.json index 7219d070..d2f81e89 100644 --- a/composer.json +++ b/composer.json @@ -25,6 +25,7 @@ "symfony/framework-bundle": "^4.0", "symfony/maker-bundle": "^1.0", "symfony/orm-pack": "^1.0", + "symfony/process": "^4.0", "symfony/profiler-pack": "^1.0", "symfony/security-bundle": "^4.0", "symfony/translation": "^4.0", diff --git a/composer.lock b/composer.lock index ca8a10a1..1e76e914 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#composer-lock-the-lock-file", "This file is @generated automatically" ], - "content-hash": "b101ecfbc1f6f2270f0e8ad326035b7e", + "content-hash": "f03b92d48946e8b2ee19466f931c826f", "packages": [ { "name": "catalyst/auth-bundle", @@ -4143,6 +4143,55 @@ ], "time": "2019-11-27T16:25:15+00:00" }, + { + "name": "symfony/process", + "version": "v4.4.9", + "source": { + "type": "git", + "url": "https://github.com/symfony/process.git", + "reference": "c714958428a85c86ab97e3a0c96db4c4f381b7f5" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/process/zipball/c714958428a85c86ab97e3a0c96db4c4f381b7f5", + "reference": "c714958428a85c86ab97e3a0c96db4c4f381b7f5", + "shasum": "" + }, + "require": { + "php": "^7.1.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "4.4-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Component\\Process\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony Process Component", + "homepage": "https://symfony.com", + "time": "2020-05-30T20:06:45+00:00" + }, { "name": "symfony/profiler-pack", "version": "v1.0.4", diff --git a/src/Controller/AnalyticsController.php b/src/Controller/AnalyticsController.php index 140405cf..6c81bea9 100644 --- a/src/Controller/AnalyticsController.php +++ b/src/Controller/AnalyticsController.php @@ -8,6 +8,8 @@ use Doctrine\ORM\EntityManagerInterface; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\Process\Process; +use Symfony\Component\Process\Exception\ProcessFailedException; use Symfony\Bundle\FrameworkBundle\Controller\Controller; use Sensio\Bundle\FrameworkExtraBundle\Configuration\IsGranted; @@ -83,7 +85,20 @@ class AnalyticsController extends Controller { $c_weekday = $one_hub['c_weekday']; $chart_weekday = $this->generateWeekdayData($c_weekday, $today, $overlaps); + $chart_all_weekdays = $this->generateAllWeekData($c_weekday, $today, $overlaps); + + // figure out the rider schedules based on the max hour values + $hour_max_values = $chart_all_weekdays['hour_max_values']; + ksort($hour_max_values); + error_log(print_r($hour_max_values, true)); + unset($chart_all_weekdays['hour_max_values']); + + // run scheduler + $shift = $this->runScheduler($hour_max_values); + $hub_data[$hub_id]['data_weekday'] = $chart_weekday; + $hub_data[$hub_id]['data_all_weekdays'] = $chart_all_weekdays; + $hub_data[$hub_id]['data_shift'] = $shift; unset($hub_data[$hub_id]['c_weekday']); } @@ -103,6 +118,51 @@ class AnalyticsController extends Controller return $this->render('analytics/forecast_submit.html.twig', $params); } + protected function runScheduler($hour_data) + { + // run python script to solve scheduling for riders + + $arg_string = implode('-', $hour_data); + + $python_cmd = '/usr/bin/python3'; + $sched_script = __DIR__ . '/../../utils/schedule_solver/solver.py'; + + error_log('running...' . $sched_script); + + $proc = new Process([$python_cmd, $sched_script, $arg_string]); + $proc->run(); + + if (!$proc->isSuccessful()) + error_log('SCHEDULER DID NOT RUN PROPERLY'); + + $res = $proc->getOutput(); + error_log($res); + + + $shifts = []; + $res_lines = explode("\n", $res); + foreach ($res_lines as $line) + { + $hour_data = explode('-', $line); + if (count($hour_data) != 2) + continue; + + $start_hour = $hour_data[0]; + $rider_count = $hour_data[1]; + + $display_shift = sprintf('%02d:00', $start_hour); + + error_log('allocating ' . $rider_count . ' for ' . $display_shift); + + $shifts[] = [ + 'label' => $display_shift, + 'count' => $rider_count, + ]; + } + + return $shifts; + } + protected function generateHubData($em, $hub, $distance_limit, DateTime $today, &$overlaps) { $date_start = DateTime::createFromFormat('Y-m-d H:i:s', '2018-01-01 00:00:00'); @@ -243,6 +303,79 @@ class AnalyticsController extends Controller return $chart_year; } + protected function generateAllWeekData($all_weekday_data, $today, $overlaps) + { + // generate all weekdays, not just one weekday + + $data = []; + + // build wekdays + $weekdays = [ + 'Monday', + 'Tuesday', + 'Wednesday', + 'Thursday', + 'Friday', + 'Saturday', + 'Sunday', + ]; + + // build hours + $hours = []; + for ($i = 0; $i < 24; $i++) + $hours[] = sprintf('%02d', $i); + + $month = $today->format('m'); + $year_data = []; + + // gather maximum for each hour + // TODO: make it for all years, for now we only track 2018 + $hour_max_values = []; + + foreach ($weekdays as $weekday) + { + foreach ($all_weekday_data as $year => $year_data) + { + // go through the hours + foreach ($hours as $hour) + { + $id = $hour + 0; + if (!isset($data[$id])) + $data[$id] = [ + 'hour' => $hour, + ]; + + // get hour data + if (isset($year_data[$month][$weekday][$hour])) + { + $year_id = 'y' . $year; + $prefix = $year_id . '_' . $weekday; + + // calculate the rider value for each JO and use that score as basis + $total_rv = $this->calculateTotalRiderValue($year_data[$month][$weekday][$hour]['jos'], $overlaps); + $rv_average = ceil($total_rv / $year_data[$month][$weekday][$hour]['count']); + + $data[$id][$prefix] = $year_data[$month][$weekday][$hour]['total']; + $data[$id][$prefix . '_count'] = $year_data[$month][$weekday][$hour]['count']; + $data[$id][$prefix . '_average'] = ceil($year_data[$month][$weekday][$hour]['total'] / $year_data[$month][$weekday][$hour]['count']); + $data[$id][$prefix . '_rv_average'] = $rv_average; + + + // set maximum hour data + if (!isset($hour_max_values[$hour]) || $rv_average > $hour_max_values[$hour]) + $hour_max_values[$hour] = $rv_average; + } + } + } + } + + // error_log(print_r($data, true)); + + $data['hour_max_values'] = $hour_max_values; + + return $data; + } + protected function generateWeekdayData($all_weekday_data, $today, $overlaps) { $data = []; @@ -329,8 +462,16 @@ class AnalyticsController extends Controller $jos = $stmt->fetchAll(); // error_log($sql); - error_log(print_r($jos, true)); + // error_log(print_r($jos, true)); return $jos; } + + protected function generateRiderSchedule() + { + } + + protected function solveRiderSchedule() + { + } } diff --git a/symfony.lock b/symfony.lock index 116ed675..900434cc 100644 --- a/symfony.lock +++ b/symfony.lock @@ -260,6 +260,9 @@ "symfony/polyfill-php73": { "version": "v1.11.0" }, + "symfony/process": { + "version": "v4.4.9" + }, "symfony/profiler-pack": { "version": "v1.0.3" }, diff --git a/templates/analytics/forecast_submit.html.twig b/templates/analytics/forecast_submit.html.twig index c8c3d28a..d4aedb2d 100644 --- a/templates/analytics/forecast_submit.html.twig +++ b/templates/analytics/forecast_submit.html.twig @@ -41,6 +41,28 @@
+ +
+
+ +
+ + + + + + + + + {% for shift_data in hub.data_shift %} + + + + + {% endfor %} + +
ShiftRiders
{{ shift_data.label }}{{ shift_data.count }}
+
@@ -170,6 +192,68 @@ l2019.strokeWidth = 3; l2019.dataFields.valueY = "y2019_rv_average"; l2019.dataFields.categoryX = "hour"; +//------------------------------------------------------------------------ + +var chart2 = am4core.create("month-all-weekday-chart-{{ hub.id }}", am4charts.XYChart); +chart2.data = {{ hub.data_all_weekdays|json_encode|raw }}; +chart2.legend = new am4charts.Legend(); + +var xAxis = chart2.xAxes.push(new am4charts.CategoryAxis()); +xAxis.dataFields.category = "hour"; +xAxis.title.text = "Hour"; +var yAxis = chart2.yAxes.push(new am4charts.ValueAxis()); +yAxis.title.text = "Orders"; +yAxis.maxPrecision = 0; + +var lmon = chart2.series.push(new am4charts.LineSeries()); +lmon.name = "Monday"; +lmon.stroke = am4core.color("#003f5c"); +lmon.strokeWidth = 2; +lmon.dataFields.valueY = "y2018_Monday_rv_average"; +lmon.dataFields.categoryX = "hour"; + +var l = chart2.series.push(new am4charts.LineSeries()); +l.name = "Tuesday"; +l.stroke = am4core.color("#374c80"); +l.strokeWidth = 2; +l.dataFields.valueY = "y2018_Tuesday_rv_average"; +l.dataFields.categoryX = "hour"; + +var l = chart2.series.push(new am4charts.LineSeries()); +l.name = "Wednesday"; +l.stroke = am4core.color("#7a5195"); +l.strokeWidth = 2; +l.dataFields.valueY = "y2018_Wednesday_rv_average"; +l.dataFields.categoryX = "hour"; + +var l = chart2.series.push(new am4charts.LineSeries()); +l.name = "Thursday"; +l.stroke = am4core.color("#bc5090"); +l.strokeWidth = 2; +l.dataFields.valueY = "y2018_Thursday_rv_average"; +l.dataFields.categoryX = "hour"; + +var l = chart2.series.push(new am4charts.LineSeries()); +l.name = "Friday"; +l.stroke = am4core.color("#ef5675"); +l.strokeWidth = 2; +l.dataFields.valueY = "y2018_Friday_rv_average"; +l.dataFields.categoryX = "hour"; + +var l = chart2.series.push(new am4charts.LineSeries()); +l.name = "Saturday"; +l.stroke = am4core.color("#ff764a"); +l.strokeWidth = 2; +l.dataFields.valueY = "y2018_Saturday_rv_average"; +l.dataFields.categoryX = "hour"; + +var l = chart2.series.push(new am4charts.LineSeries()); +l.name = "Sunday"; +l.stroke = am4core.color("#ffa600"); +l.strokeWidth = 2; +l.dataFields.valueY = "y2018_Sunday_rv_average"; +l.dataFields.categoryX = "hour"; + {% endfor %} diff --git a/utils/schedule_solver/solver.py b/utils/schedule_solver/solver.py new file mode 100644 index 00000000..be97e540 --- /dev/null +++ b/utils/schedule_solver/solver.py @@ -0,0 +1,118 @@ +from __future__ import print_function +from ortools.linear_solver import pywraplp +import sys + +def main(): + # get arguments + hours_string = sys.argv[1] + hours_data = hours_string.split('-') + + # initialize hours + hours = [ + ['00', 0], + ['01', 0], + ['02', 0], + ['03', 0], + ['04', 0], + ['05', 0], + ['06', 0], + ['07', 0], + ['08', 0], + ['09', 0], + ['10', 0], + ['11', 0], + ['12', 0], + ['13', 0], + ['14', 0], + ['15', 0], + ['16', 0], + ['17', 0], + ['18', 0], + ['19', 0], + ['20', 0], + ['21', 0], + ['22', 0], + ['23', 0]] + + # set hours from argument + for i in range(0, len(hours_data)): + hours[i][1] = int(hours_data[i]) + + + # all shifts available + shifts = [ + ['00 - 09', 0, 1, 2, 3, 4, 5, 6, 7, 8], + ['01 - 10', 1, 2, 3, 4, 5, 6, 7, 8, 9], + ['02 - 11', 2, 3, 4, 5, 6, 7, 8, 9, 10], + ['03 - 12', 3, 4, 5, 6, 7, 8, 9, 10, 11], + ['04 - 13', 4, 5, 6, 7, 8, 9, 10, 11, 12], + ['05 - 14', 5, 6, 7, 8, 9, 10, 11, 12, 13], + ['06 - 15', 6, 7, 8, 9, 10, 11, 12, 13, 14], + ['07 - 16', 7, 8, 9, 10, 11, 12, 13, 14, 15], + ['08 - 17', 8, 9, 10, 11, 12, 13, 14, 15, 16], + ['09 - 18', 9, 10, 11, 12, 13, 14, 15, 16, 17], + ['10 - 19', 10, 11, 12, 13, 14, 15, 16, 17, 18], + ['11 - 20', 11, 12, 13, 14, 15, 16, 17, 18, 19], + ['12 - 21', 12, 13, 14, 15, 16, 17, 18, 19, 20], + ['13 - 22', 13, 14, 15, 16, 17, 18, 19, 20, 21], + ['14 - 23', 14, 15, 16, 17, 18, 19, 20, 21, 22], + ['15 - 00', 15, 16, 17, 18, 19, 20, 21, 22, 23], + ['16 - 01', 16, 17, 18, 19, 20, 21, 22, 23, 0], + ['17 - 02', 17, 18, 19, 20, 21, 22, 23, 0, 1], + ['18 - 03', 18, 19, 20, 21, 22, 23, 0, 1, 2], + ['19 - 04', 19, 20, 21, 22, 23, 0, 1, 2, 3], + ['20 - 05', 20, 21, 22, 23, 0, 1, 2, 3, 4], + ['21 - 06', 21, 22, 23, 0, 1, 2, 3, 4, 5], + ['22 - 07', 22, 23, 0, 1, 2, 3, 4, 5, 6], + ['23 - 08', 23, 0, 1, 2, 3, 4, 5, 6, 7]] + + # instantiate glop solver + solver = pywraplp.Solver('SolveSchedule', pywraplp.Solver.CBC_MIXED_INTEGER_PROGRAMMING) + + # solver variables + solv_shifts = [[]] * len(shifts) + + # objective + objective = solver.Objective() + + # variables for shifts + for i in range(0, len(shifts)): + solv_shifts[i] = solver.IntVar(0, solver.infinity(), shifts[i][0]) + # objective is to minimize number of shifts + objective.SetCoefficient(solv_shifts[i], 1) + + objective.SetMinimization() + + # set constraints + constraints = [0] * len(hours) + for hour_index in range(0, len(hours)): + # hour personnel should be equal or less than shift personnel that covers those hours + constraints[hour_index] = solver.Constraint(hours[hour_index][1], solver.infinity()) + for shift_index in range(0, len(shifts)): + # get each shift's hour coverage + for shift_hour_index in range(1, len(shifts[shift_index])): + # check if shift covers that hour + # NOTE: this can still be optimized later via indexing + if shifts[shift_index][shift_hour_index] == hour_index: + # print('hour', hour_index, 'in shift index -', shift_index) + constraints[hour_index].SetCoefficient(solv_shifts[shift_index], 1) + + # solve it! + status = solver.Solve() + + if status == solver.OPTIMAL: + #print('Optimal solution found!') + #print('Number of variables =', solver.NumVariables()) + #print('Number of constraints =', solver.NumConstraints()) + for i in range(0, len(shifts)): + result = solv_shifts[i].solution_value() + if result > 0: + print(i, '-', int(solv_shifts[i].solution_value()), sep='') + else: + if status == solver.FEASIBLE: + print('Feasible solution found!') + else: + print('Could not solve problem.') + +if __name__ == '__main__': + main()