From 0911a1787e2d1345ce850ce987c279757c480a03 Mon Sep 17 00:00:00 2001 From: Kendrick Chan Date: Wed, 3 Jun 2020 01:18:12 +0800 Subject: [PATCH 1/3] Create graph for overlaps #409 --- src/Controller/AnalyticsController.php | 97 +++++++++++++++++-- templates/analytics/forecast_submit.html.twig | 38 ++++++++ 2 files changed, 128 insertions(+), 7 deletions(-) diff --git a/src/Controller/AnalyticsController.php b/src/Controller/AnalyticsController.php index 8dfbddde..140405cf 100644 --- a/src/Controller/AnalyticsController.php +++ b/src/Controller/AnalyticsController.php @@ -62,13 +62,14 @@ class AnalyticsController extends Controller $hub_data = []; $hub_coverage = []; + $overlaps = []; foreach ($hub_list as $key => $hub_id) { + $dist = $distances[$key]; $hub = $em->getRepository(Hub::class)->find($hub_id); $coords = $hub->getCoordinates(); - $dist = $distances[$key]; - $hub_data[$hub_id] = $this->generateHubData($em, $hub, $dist, $today); + $hub_data[$hub_id] = $this->generateHubData($em, $hub, $dist, $today, $overlaps); $hub_coverage[] = [ 'longitude' => $coords->getLongitude(), @@ -77,16 +78,32 @@ class AnalyticsController extends Controller ]; } + // reprocess weekday data to account for overlap + foreach ($hub_data as $hub_id => $one_hub) + { + $c_weekday = $one_hub['c_weekday']; + $chart_weekday = $this->generateWeekdayData($c_weekday, $today, $overlaps); + $hub_data[$hub_id]['data_weekday'] = $chart_weekday; + + unset($hub_data[$hub_id]['c_weekday']); + } + + // error_log(print_r($overlaps, true)); + + // get job orders not covered by hubs + $not_covered = $this->generateNotCoveredData($em, $hub_coverage, $today); + $params = [ 'date' => $today, 'hub_list' => $hub_data, 'hub_coverage' => $hub_coverage, + 'not_covered' => $not_covered, ]; return $this->render('analytics/forecast_submit.html.twig', $params); } - protected function generateHubData($em, $hub, $distance_limit, DateTime $today) + 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'); $date_end = new DateTime(); @@ -95,7 +112,7 @@ class AnalyticsController extends Controller // $hub = $em->getRepository(Hub::class)->find($hub_id); $conn = $em->getConnection(); - // get job order data + // get job order data (job orders within coverage area) $jos = $this->generateJobOrderData($conn, $hub, $distance_limit); // initialize counters @@ -116,6 +133,9 @@ class AnalyticsController extends Controller $hour = $date->format('H'); $week = $date->format('W'); + $jo_id = $jo['id']; + $hub_id = $hub->getID(); + // year day if (!isset($c_day[$year][$month][$day])) $c_day[$year][$month][$day] = 0; @@ -126,9 +146,11 @@ class AnalyticsController extends Controller { $c_weekday[$year][$month][$weekday][$hour]['total'] = 0; $c_weekday[$year][$month][$weekday][$hour]['count'] = 0; + $c_weekday[$year][$month][$weekday][$hour]['jos'] = []; } $c_weekday[$year][$month][$weekday][$hour]['total']++; + $c_weekday[$year][$month][$weekday][$hour]['jos'][$jo_id] = $jo_id; if (!isset($c_week_count[$year][$month][$week][$weekday][$hour])) { @@ -136,12 +158,19 @@ class AnalyticsController extends Controller $c_week_count[$year][$month][$week][$weekday][$hour] = 1; $c_weekday[$year][$month][$weekday][$hour]['count']++; } + + // track overlaps (jo that can be handled by more than one hub) + if (!isset($overlaps[$jo_id])) + { + $overlaps[$jo_id] = []; + } + $overlaps[$jo_id][$hub_id] = $hub_id; } // error_log(print_r($c_weekday, true)); $chart_year = $this->generateYearData($date_start, $date_end, $c_day); - $chart_weekday = $this->generateWeekdayData($c_weekday, $today); + // $chart_weekday = $this->generateWeekdayData($c_weekday, $today); // error_log(print_r($chart_weekday, true)); @@ -149,7 +178,9 @@ class AnalyticsController extends Controller 'id' => $hub->getID(), 'label' => $hub->getName(), 'data_year' => $chart_year, - 'data_weekday' => $chart_weekday, + // 'data_weekday' => $chart_weekday, + 'c_weekday' => $c_weekday, // sending raw weekday data because we need to process overlaps + // TODO: refactor this pls ]; return $params; @@ -212,7 +243,7 @@ class AnalyticsController extends Controller return $chart_year; } - protected function generateWeekdayData($all_weekday_data, $today) + protected function generateWeekdayData($all_weekday_data, $today, $overlaps) { $data = []; @@ -241,13 +272,65 @@ class AnalyticsController extends Controller { $year_id = 'y' . $year; + // calculate the rider value for each JO and use that score as basis + $total_rv = $this->calculateTotalRiderValue($year_data[$month][$weekday][$hour]['jos'], $overlaps); + $data[$id][$year_id] = $year_data[$month][$weekday][$hour]['total']; $data[$id][$year_id . '_count'] = $year_data[$month][$weekday][$hour]['count']; $data[$id][$year_id . '_average'] = ceil($year_data[$month][$weekday][$hour]['total'] / $year_data[$month][$weekday][$hour]['count']); + $data[$id][$year_id . '_rv_average'] = ceil($total_rv / $year_data[$month][$weekday][$hour]['count']); } } } return $data; } + + protected function calculateTotalRiderValue($jos, $overlaps) + { + // rider value = 1 / number of hubs (overlaps) that can service JO + + $sum = 0.0; + $jo_count = count($jos); + + foreach ($jos as $jo_id) + { + $hub_count = count($overlaps[$jo_id]); + $rv = 1 / $hub_count; + $sum += $rv; + } + + return $sum; + } + + protected function generateNotCoveredData($em, $hub_coverage, $today) + { + $conn = $em->getConnection(); + + $month = $today->format('m'); + $weekday = $today->format('N') - 1; + + $wheres = []; + foreach ($hub_coverage as $hub_data) + { + $long = $hub_data['longitude']; + $lat = $hub_data['latitude']; + $dist = $hub_data['distance']; + + // get areas not covered + $wheres[] = "st_distance(coordinates, Point($long, $lat)) * 111195 > $dist"; + } + + $where_string = implode(' and ', $wheres); + + $sql = "select st_x(coordinates) as longitude, st_y(coordinates) as latitude, id, date_schedule from job_order where $where_string and status <> 'cancelled' and month(date_schedule) = $month and weekday(date_schedule) = $weekday order by date_schedule asc"; + $stmt = $conn->prepare($sql); + $stmt->execute(); + $jos = $stmt->fetchAll(); + + // error_log($sql); + error_log(print_r($jos, true)); + + return $jos; + } } diff --git a/templates/analytics/forecast_submit.html.twig b/templates/analytics/forecast_submit.html.twig index 3951a231..c8c3d28a 100644 --- a/templates/analytics/forecast_submit.html.twig +++ b/templates/analytics/forecast_submit.html.twig @@ -38,6 +38,9 @@
+ +
+
@@ -67,6 +70,15 @@ var map = L.map('map_coverage').setView([{% trans %}default_lat{% endtrans %}, { L.circle([{{ cover.latitude }}, {{ cover.longitude }}], { radius: {{ cover.distance }} }).addTo(map); {% endfor %} +//------------------------------------------------------------------------ + +// display not covered +{% for jo_na in not_covered %} +L.marker([{{ jo_na.latitude }}, {{ jo_na.longitude }}]).addTo(map); +{% endfor %} + +//------------------------------------------------------------------------ + {% for hub in hub_list %} // create chart instance var chart = am4core.create("year-day-chart-{{ hub.id }}", am4charts.XYChart); @@ -132,6 +144,32 @@ l2019.strokeWidth = 3; l2019.dataFields.valueY = "y2019_average"; l2019.dataFields.categoryX = "hour"; +//------------------------------------------------------------------------ + +var chart2 = am4core.create("month-weekday-chart-rv-{{ hub.id }}", am4charts.XYChart); +chart2.data = {{ hub.data_weekday|json_encode|raw }}; + +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 l2018 = chart2.series.push(new am4charts.LineSeries()); +l2018.name = "2018"; +l2018.stroke = am4core.color("#0000FF"); +l2018.strokeWidth = 2; +l2018.dataFields.valueY = "y2018_rv_average"; +l2018.dataFields.categoryX = "hour"; + +var l2019 = chart2.series.push(new am4charts.LineSeries()); +l2019.name = "2019"; +l2019.stroke = am4core.color("#FF0000"); +l2019.strokeWidth = 3; +l2019.dataFields.valueY = "y2019_rv_average"; +l2019.dataFields.categoryX = "hour"; + {% endfor %} From d94e2353b0cddd8b89486d208579d798604ebbe0 Mon Sep 17 00:00:00 2001 From: Kendrick Chan Date: Thu, 11 Jun 2020 01:15:58 +0800 Subject: [PATCH 2/3] 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() From a2fdb346b3a66b7edfa5ce9e3af86c5d91d9b8b1 Mon Sep 17 00:00:00 2001 From: Kendrick Chan Date: Sun, 14 Jun 2020 14:12:32 +0800 Subject: [PATCH 3/3] Refactor schedule solver to solver per weekday and add charts and tables to template #409 --- src/Controller/AnalyticsController.php | 217 +++++++++++++----- templates/analytics/forecast_submit.html.twig | 93 +++++++- utils/schedule_solver/solver.py | 147 +++++++----- 3 files changed, 337 insertions(+), 120 deletions(-) diff --git a/src/Controller/AnalyticsController.php b/src/Controller/AnalyticsController.php index 6c81bea9..dbbdbf29 100644 --- a/src/Controller/AnalyticsController.php +++ b/src/Controller/AnalyticsController.php @@ -24,6 +24,53 @@ use App\Entity\Hub; class AnalyticsController extends Controller { + protected $weekdays = [ + 'Monday', + 'Tuesday', + 'Wednesday', + 'Thursday', + 'Friday', + 'Saturday', + 'Sunday', + ]; + + protected $day_shifts = [ + ['Mon - Sat', 0, 1, 2, 3, 4, 5], // Mon - Sat + ['Tue - Sun', 1, 2, 3, 4, 5, 6], // Tue - Sun + ['Wed - Mon', 2, 3, 4, 5, 6, 0], // Wed - Mon + ['Thu - Tue', 3, 4, 5, 6, 0, 1], // Thu - Tue + ['Fri - Wed', 4, 5, 6, 0, 1, 2], // Fri - Wed + ['Sat - Thu', 5, 6, 0, 1, 2, 3], // Sat - Thu + ['Sun - Fri', 6, 0, 1, 2, 3, 4], // Sun - Fri + ]; + + protected $hour_shifts = [ + ['00:00 - 09:00', 0, 1, 2, 3, 4, 5, 6, 7, 8], + ['01:00 - 10:00', 1, 2, 3, 4, 5, 6, 7, 8, 9], + ['02:00 - 11:00', 2, 3, 4, 5, 6, 7, 8, 9, 10], + ['03:00 - 12:00', 3, 4, 5, 6, 7, 8, 9, 10, 11], + ['04:00 - 13:00', 4, 5, 6, 7, 8, 9, 10, 11, 12], + ['05:00 - 14:00', 5, 6, 7, 8, 9, 10, 11, 12, 13], + ['06:00 - 15:00', 6, 7, 8, 9, 10, 11, 12, 13, 14], + ['07:00 - 16:00', 7, 8, 9, 10, 11, 12, 13, 14, 15], + ['08:00 - 17:00', 8, 9, 10, 11, 12, 13, 14, 15, 16], + ['09:00 - 18:00', 9, 10, 11, 12, 13, 14, 15, 16, 17], + ['10:00 - 19:00', 10, 11, 12, 13, 14, 15, 16, 17, 18], + ['11:00 - 20:00', 11, 12, 13, 14, 15, 16, 17, 18, 19], + ['12:00 - 21:00', 12, 13, 14, 15, 16, 17, 18, 19, 20], + ['13:00 - 22:00', 13, 14, 15, 16, 17, 18, 19, 20, 21], + ['14:00 - 23:00', 14, 15, 16, 17, 18, 19, 20, 21, 22], + ['15:00 - 00:00', 15, 16, 17, 18, 19, 20, 21, 22, 23], + ['16:00 - 01:00', 16, 17, 18, 19, 20, 21, 22, 23, 0], + ['17:00 - 02:00', 17, 18, 19, 20, 21, 22, 23, 0, 1], + ['18:00 - 03:00', 18, 19, 20, 21, 22, 23, 0, 1, 2], + ['19:00 - 04:00', 19, 20, 21, 22, 23, 0, 1, 2, 3], + ['20:00 - 05:00', 20, 21, 22, 23, 0, 1, 2, 3, 4], + ['21:00 - 06:00', 21, 22, 23, 0, 1, 2, 3, 4, 5], + ['22:00 - 07:00', 22, 23, 0, 1, 2, 3, 4, 5, 6], + ['23:00 - 08:00', 23, 0, 1, 2, 3, 4, 5, 6, 7], + ]; + /** * @Menu(selected="analytics_forecast") * @IsGranted("analytics.forecast") @@ -58,7 +105,8 @@ class AnalyticsController extends Controller $hub_list = $req->request->get('hub_ids', []); $distances = $req->request->get('distances', []); $today = DateTime::createFromFormat('d M Y', $req->request->get('date')); - error_log(print_r($hub_list, true)); + $month = $today->format('m'); + // error_log(print_r($hub_list, true)); // $hub_list = [ 6, 4, 36, 7, 8, 126, 127, 18, 12, 9, 60, 10, 21, 135 ]; @@ -88,17 +136,29 @@ class AnalyticsController extends Controller $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']); + $scheduler_data = $chart_all_weekdays['scheduler_data']; + // error_log(print_r($scheduler_data, true)); + unset($chart_all_weekdays['scheduler_data']); // run scheduler - $shift = $this->runScheduler($hour_max_values); + // send 2018 + month data + $sched_res = $this->runScheduler($scheduler_data['2018'][$month]); + + // tally total JOs for the month + $total_jos = 0; + foreach ($scheduler_data['2018'][$month] as $sday_data) + { + foreach ($sday_data as $shour_data) + { + $total_jos += $shour_data; + } + } $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; + $hub_data[$hub_id]['data_shift'] = $sched_res['weekday_shifts']; + $hub_data[$hub_id]['total_jos'] = $total_jos; + $hub_data[$hub_id]['total_riders'] = $sched_res['total_riders']; unset($hub_data[$hub_id]['c_weekday']); } @@ -118,18 +178,26 @@ class AnalyticsController extends Controller return $this->render('analytics/forecast_submit.html.twig', $params); } - protected function runScheduler($hour_data) + protected function runScheduler($scheduler_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); + // go through the days + $args = [ + $python_cmd, + $sched_script, + ]; + foreach ($scheduler_data as $weekday_data) + $args[] = implode('-', $weekday_data); - $proc = new Process([$python_cmd, $sched_script, $arg_string]); + error_log(print_r($args, true)); + + // error_log('running...' . $sched_script); + + $proc = new Process($args); $proc->run(); if (!$proc->isSuccessful()) @@ -139,28 +207,74 @@ class AnalyticsController extends Controller error_log($res); - $shifts = []; - $res_lines = explode("\n", $res); - foreach ($res_lines as $line) + // segregate into weekdays + $day_data = []; + $i = 0; + foreach ($scheduler_data as $weekday_data) { - $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, + $total_jos = 0; + foreach ($weekday_data as $hourly_jo) + $total_jos += $hourly_jo; + + $day_data[$i] = [ + 'weekday' => $this->weekdays[$i], + 'total_jos' => $total_jos, + 'total_riders' => 0, + 'shifts' => [], ]; + + $i++; } - return $shifts; + $shifts = []; + $res_lines = explode("\n", $res); + $total_riders = 0; + foreach ($res_lines as $line) + { + // format is day shift - hour shift - number of riders + $shift_data = explode('-', $line); + if (count($shift_data) != 3) + continue; + + $day_shift_index = $shift_data[0]; + $hour_shift_index = $shift_data[1]; + $rider_count = $shift_data[2]; + + $total_riders += $rider_count; + + $label = $this->day_shifts[$day_shift_index][0] . ' ' . $this->hour_shifts[$hour_shift_index][0]; + + // initialize hours + $rider_hours = []; + for ($i = 0; $i < 24; $i++) + $rider_hours[$i] = 0; + for ($i = 1; $i < count($this->hour_shifts[$hour_shift_index]); $i++) + $rider_hours[$this->hour_shifts[$hour_shift_index][$i]] = 1; + + error_log('allocating ' . $rider_count . ' for ' . $label); + + // add shifts to the weekday + for ($i = 1; $i < count($this->day_shifts[$day_shift_index]); $i++) + { + $day = $this->day_shifts[$day_shift_index][$i]; + + $day_data[$day]['shifts'][] = [ + 'label' => $label, + 'count' => $rider_count, + 'hours' => $rider_hours, + ]; + + $day_data[$day]['total_riders'] += $rider_count; + } + } + + + $data = [ + 'weekday_shifts' => $day_data, + 'total_riders' => $total_riders, + ]; + + return $data; } protected function generateHubData($em, $hub, $distance_limit, DateTime $today, &$overlaps) @@ -305,21 +419,8 @@ class AnalyticsController extends Controller 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++) @@ -327,12 +428,10 @@ class AnalyticsController extends Controller $month = $today->format('m'); $year_data = []; + $scheduler_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 ($this->weekdays as $weekday) { foreach ($all_weekday_data as $year => $year_data) { @@ -346,11 +445,10 @@ class AnalyticsController extends Controller ]; // get hour data + $year_id = 'y' . $year; + $prefix = $year_id . '_' . $weekday; 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']); @@ -360,19 +458,24 @@ class AnalyticsController extends Controller $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; + // assign scheduler data + $scheduler_data[$year][$month][$weekday][$hour] = $rv_average; + } + else + { + if (!isset($scheduler_data[$year][$month][$weekday][$hour])) + { + $data[$id][$prefix . '_rv_average'] = 0; + $scheduler_data[$year][$month][$weekday][$hour] = 0; + } } } } } + $data['scheduler_data'] = $scheduler_data; + // error_log(print_r($data, true)); - - $data['hour_max_values'] = $hour_max_values; - return $data; } diff --git a/templates/analytics/forecast_submit.html.twig b/templates/analytics/forecast_submit.html.twig index d4aedb2d..2fafd57f 100644 --- a/templates/analytics/forecast_submit.html.twig +++ b/templates/analytics/forecast_submit.html.twig @@ -2,6 +2,26 @@ {% block stylesheets %} + {% endblock %} {% block body %} @@ -45,24 +65,83 @@
+ {% for day_data in hub.data_shift %}
- +
- - + + + + + + + + + + + + + + + + + + + + + + + + + - {% for shift_data in hub.data_shift %} + {% for shift_data in day_data.shifts %} + {% for i in 1..shift_data.count %} - - + + {% for hour_coverage in shift_data.hours %} + {% if hour_coverage %} + + {% else %} + + {% endif %} + {% endfor %} {% endfor %} + {% endfor %}
ShiftRiders{{ day_data.weekday }}000102030405060708091011121314151617181920212223
{{ shift_data.label }}{{ shift_data.count }}{{ shift_data.label }}
+ {% endfor %} + + + + + + + + + + + {% for i in 0..6 %} + + + + + + + {% endfor %} + + + + + + + +
Day# JO# RiderJO per Rider
{{ hub.data_shift[i].weekday }}{{ hub.data_shift[i].total_jos }}{{ hub.data_shift[i].total_riders }}{{ (hub.data_shift[i].total_jos / hub.data_shift[i].total_riders) | round(1, 'common') }}
Overall{{ hub.total_jos }}{{ hub.total_riders }}{{ (hub.total_jos / hub.total_riders) | round(1, 'common') }}
@@ -144,6 +223,7 @@ s2020.dataFields.categoryX = "date"; var chart2 = am4core.create("month-weekday-chart-{{ hub.id }}", am4charts.XYChart); chart2.data = {{ hub.data_weekday|json_encode|raw }}; +chart2.legend = new am4charts.Legend(); var xAxis = chart2.xAxes.push(new am4charts.CategoryAxis()); xAxis.dataFields.category = "hour"; @@ -170,6 +250,7 @@ l2019.dataFields.categoryX = "hour"; var chart2 = am4core.create("month-weekday-chart-rv-{{ hub.id }}", am4charts.XYChart); chart2.data = {{ hub.data_weekday|json_encode|raw }}; +chart2.legend = new am4charts.Legend(); var xAxis = chart2.xAxes.push(new am4charts.CategoryAxis()); xAxis.dataFields.category = "hour"; diff --git a/utils/schedule_solver/solver.py b/utils/schedule_solver/solver.py index be97e540..f980cc44 100644 --- a/utils/schedule_solver/solver.py +++ b/utils/schedule_solver/solver.py @@ -3,44 +3,50 @@ from ortools.linear_solver import pywraplp import sys def main(): - # get arguments - hours_string = sys.argv[1] - hours_data = hours_string.split('-') - # initialize hours + # days + days = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'] + + # 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]] + '00', + '01', + '02', + '03', + '04', + '05', + '06', + '07', + '08', + '09', + '10', + '11', + '12', + '13', + '14', + '15', + '16', + '17', + '18', + '19', + '20', + '21', + '22', + '23'] - # set hours from argument - for i in range(0, len(hours_data)): - hours[i][1] = int(hours_data[i]) + # initialize required hours - req_hours[days][hours] + req_hours = [[0 for x in range(len(hours))] for y in range(len(days))] + # get arguments + # there will be 7 arguments, monday to sunday schedule + for day_index in range(0, len(days)): + hours_string = sys.argv[day_index + 1] + hours_data = hours_string.split('-') + for hour_index in range(0, len(hours)): + req_hours[day_index][hour_index] = int(hours_data[hour_index]) - # all shifts available - shifts = [ + # all hour shifts available + hour_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], @@ -66,48 +72,75 @@ def main(): ['22 - 07', 22, 23, 0, 1, 2, 3, 4, 5, 6], ['23 - 08', 23, 0, 1, 2, 3, 4, 5, 6, 7]] + # all possible days riders come in + day_shifts = [ + ['Mon - Sat', 0, 1, 2, 3, 4, 5], # Mon - Sat + ['Tue - Sun', 1, 2, 3, 4, 5, 6], # Tue - Sun + ['Wed - Mon', 2, 3, 4, 5, 6, 0], # Wed - Mon + ['Thu - Tue', 3, 4, 5, 6, 0, 1], # Thu - Tue + ['Fri - Wed', 4, 5, 6, 0, 1, 2], # Fri - Wed + ['Sat - Thu', 5, 6, 0, 1, 2, 3], # Sat - Thu + ['Sun - Fri', 6, 0, 1, 2, 3, 4]] # Sun - Fri + + # build shift lookup index + + # instantiate glop solver solver = pywraplp.Solver('SolveSchedule', pywraplp.Solver.CBC_MIXED_INTEGER_PROGRAMMING) # solver variables - solv_shifts = [[]] * len(shifts) + solv_shifts = [[0 for x in range(len(hour_shifts))] for y in range(len(day_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) - + for day_index in range(0, len(day_shifts)): + for hour_index in range(0, len(hour_shifts)): + solv_shifts[day_index][hour_index] = solver.IntVar(0, solver.infinity(), day_shifts[day_index][0] + ' ' + hour_shifts[hour_index][0]) + # objective is to minimize number of shifts + objective.SetCoefficient(solv_shifts[day_index][hour_index], 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) + constraints = [[0 for x in range(len(hours))] for y in range(len(days))] + # go through all days + for day_index in range(0, len(days)): + # go through all hours + for hour_index in range(0, len(hours)): + # hour personnel should be equal or less than shift personnel that covers those hours + # set the required manpower for that day + hour combo + # print('setting constraint for', day_index, '-', hour_index, '=', req_hours[day_index][hour_index]) + constraints[day_index][hour_index] = solver.Constraint(req_hours[day_index][hour_index], solver.infinity()) + # go through all day shifts + for shift_day in range(0, len(day_shifts)): + # go through days inside day shift + for shift_day_index in range(1, len(day_shifts[shift_day])): + # is day shift part of that day? + if day_index == day_shifts[shift_day][shift_day_index]: + # go through all hour shifts + for shift_hour in range(0, len(hour_shifts)): + # go through all hours inside hour shift + for shift_hour_index in range(1, len(hour_shifts[shift_hour])): + # is hour shift part of the hour + if hour_index == hour_shifts[shift_hour][shift_hour_index]: + # print(day_index, ' - ', hour_index, ' vs ', day_shifts[shift_day][shift_day_index], ' - ', hour_shifts[shift_hour][shift_hour_index]) + # add it to constraint + constraints[day_index][hour_index].SetCoefficient(solv_shifts[shift_day][shift_hour], 1) # solve it! status = solver.Solve() + #print('Number of variables =', solver.NumVariables()) + #print('Number of constraints =', solver.NumConstraints()) 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='') + for day_index in range(0, len(day_shifts)): + for hour_index in range(0, len(hour_shifts)): + result = solv_shifts[day_index][hour_index].solution_value() + if result > 0: + print(day_index, hour_index, int(solv_shifts[day_index][hour_index].solution_value()), sep='-') + #print(day_shifts[day_index][0], ' ', hour_shifts[hour_index][0], ' = ', int(solv_shifts[day_index][hour_index].solution_value()), sep='') else: if status == solver.FEASIBLE: print('Feasible solution found!')