├── .github └── workflows │ └── ci.yml ├── .gitignore ├── README.md ├── actors ├── __init__.py ├── actor.py ├── courier.py ├── dispatcher.py ├── user.py └── world.py ├── alembic.ini ├── ddbb ├── __init__.py ├── config.py ├── env.py ├── load_instances.py ├── queries │ ├── __init__.py │ ├── couriers_instance_data_query.py │ └── orders_instance_data_query.py ├── script.py.mako ├── tables │ ├── __init__.py │ └── base.py └── versions │ ├── 65698a586556_create_instance_data_tables.py │ └── b2388948ee69_create_metrics_tables.py ├── docker-compose.yml ├── docker └── colombia.dockerfile ├── instances ├── 0 │ ├── couriers.csv │ └── orders.csv ├── 1 │ ├── couriers.csv │ └── orders.csv ├── 2 │ ├── couriers.csv │ └── orders.csv ├── 3 │ ├── couriers.csv │ └── orders.csv ├── 4 │ ├── couriers.csv │ └── orders.csv ├── 5 │ ├── couriers.csv │ └── orders.csv ├── 6 │ ├── couriers.csv │ └── orders.csv ├── 7 │ ├── couriers.csv │ └── orders.csv ├── 8 │ ├── couriers.csv │ └── orders.csv ├── 9 │ ├── couriers.csv │ └── orders.csv ├── 10 │ ├── couriers.csv │ └── orders.csv ├── 11 │ ├── couriers.csv │ └── orders.csv ├── 12 │ ├── couriers.csv │ └── orders.csv ├── 13 │ ├── couriers.csv │ └── orders.csv ├── 14 │ ├── couriers.csv │ └── orders.csv ├── 15 │ ├── couriers.csv │ └── orders.csv ├── 16 │ ├── couriers.csv │ └── orders.csv ├── 17 │ ├── couriers.csv │ └── orders.csv ├── 18 │ ├── couriers.csv │ └── orders.csv ├── 19 │ ├── couriers.csv │ └── orders.csv ├── 20 │ ├── couriers.csv │ └── orders.csv ├── 21 │ ├── couriers.csv │ └── orders.csv ├── 22 │ ├── couriers.csv │ └── orders.csv └── 23 │ ├── couriers.csv │ └── orders.csv ├── objects ├── __init__.py ├── location.py ├── matching_metric.py ├── notification.py ├── order.py ├── route.py ├── stop.py └── vehicle.py ├── policies ├── __init__.py ├── courier │ ├── __init__.py │ ├── acceptance │ │ ├── __init__.py │ │ ├── absolute.py │ │ ├── courier_acceptance_policy.py │ │ └── random_uniform.py │ ├── movement │ │ ├── __init__.py │ │ ├── courier_movement_policy.py │ │ └── osrm.py │ └── movement_evaluation │ │ ├── __init__.py │ │ ├── courier_movement_evaluation_policy.py │ │ ├── geohash_neighbors.py │ │ └── still.py ├── dispatcher │ ├── __init__.py │ ├── buffering │ │ ├── __init__.py │ │ ├── dispatcher_buffering_policy.py │ │ └── rolling_horizon.py │ ├── cancellation │ │ ├── __init__.py │ │ ├── dispatcher_cancellation_policy.py │ │ └── static.py │ ├── matching │ │ ├── __init__.py │ │ ├── dispatcher_matching_policy.py │ │ ├── greedy.py │ │ └── myopic.py │ ├── prepositioning │ │ ├── __init__.py │ │ ├── dispatcher_prepositioning_policy.py │ │ └── naive.py │ └── prepositioning_evaluation │ │ ├── __init__.py │ │ ├── dispatcher_prepositioning_evaluation_policy.py │ │ └── fixed.py ├── policy.py └── user │ ├── __init__.py │ └── cancellation │ ├── __init__.py │ ├── random.py │ └── user_cancellation_policy.py ├── requirements.txt ├── services ├── __init__.py ├── metrics_service.py ├── optimization_service │ ├── __init__.py │ ├── graph │ │ ├── __init__.py │ │ ├── graph.py │ │ └── graph_builder.py │ ├── model │ │ ├── __init__.py │ │ ├── constraints │ │ │ ├── __init__.py │ │ │ ├── balance_constraint.py │ │ │ ├── courier_assignment_constraint.py │ │ │ ├── model_constraint.py │ │ │ └── route_assignment_constraint.py │ │ ├── graph_model_builder.py │ │ ├── mip_model_builder.py │ │ ├── model_builder.py │ │ └── optimization_model.py │ └── problem │ │ ├── __init__.py │ │ ├── matching_problem.py │ │ └── matching_problem_builder.py └── osrm_service.py ├── settings.py ├── simulate.py ├── tests ├── __init__.py ├── functional │ ├── __init__.py │ ├── actors │ │ ├── __init__.py │ │ ├── tests_courier.py │ │ ├── tests_dispatcher.py │ │ └── tests_user.py │ ├── objects │ │ ├── __init__.py │ │ └── tests_route.py │ ├── policies │ │ ├── __init__.py │ │ ├── tests_greedy_matching_policy.py │ │ └── tests_myopic_matching_policy.py │ └── services │ │ ├── __init__.py │ │ └── tests_osrm_service.py ├── test_suite.py └── test_utils.py └── utils ├── __init__.py ├── datetime_utils.py ├── diagrams ├── courier.png ├── dispatcher.png └── user.png └── logging_utils.py /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: Run Python Tests 2 | on: 3 | push: 4 | branches: 5 | - master 6 | pull_request: 7 | branches: 8 | - master 9 | 10 | jobs: 11 | build: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v2 15 | - name: Install Python 3 16 | uses: actions/setup-python@v1 17 | with: 18 | python-version: 3.7 19 | - name: Install dependencies 20 | run: | 21 | python -m pip install --upgrade pip 22 | python -m pip install -i https://pypi.gurobi.com gurobipy 23 | python -m pip install -r requirements.txt 24 | - name: Run tests with unittest 25 | run: python3 -m unittest -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | *.py,cover 51 | .hypothesis/ 52 | .pytest_cache/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | target/ 76 | 77 | # Jupyter Notebook 78 | .ipynb_checkpoints 79 | 80 | # IPython 81 | profile_default/ 82 | ipython_config.py 83 | 84 | # pyenv 85 | .python-version 86 | 87 | # pipenv 88 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 89 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 90 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 91 | # install all needed dependencies. 92 | #Pipfile.lock 93 | 94 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 95 | __pypackages__/ 96 | 97 | # Celery stuff 98 | celerybeat-schedule 99 | celerybeat.pid 100 | 101 | # SageMath parsed files 102 | *.sage.py 103 | 104 | # Environments 105 | .env 106 | .venv 107 | env/ 108 | venv/ 109 | ENV/ 110 | env.bak/ 111 | venv.bak/ 112 | 113 | # Spyder project settings 114 | .spyderproject 115 | .spyproject 116 | 117 | # Rope project settings 118 | .ropeproject 119 | 120 | # mkdocs documentation 121 | /site 122 | 123 | # mypy 124 | .mypy_cache/ 125 | .dmypy.json 126 | dmypy.json 127 | 128 | # Pyre type checker 129 | .pyre/ 130 | 131 | # Reserved info 132 | rappi/ 133 | 134 | # Pycharm 135 | .idea/ 136 | 137 | # VSCode 138 | .vscode/ 139 | 140 | -------------------------------------------------------------------------------- /actors/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sebastian-quintero/mdrp-sim/94656681006f7bbc32b2f1970e288944c52d308a/actors/__init__.py -------------------------------------------------------------------------------- /actors/actor.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from typing import Optional 3 | 4 | from simpy import Environment, Process 5 | 6 | from utils.logging_utils import log, configure_logs 7 | 8 | 9 | @dataclass 10 | class Actor: 11 | """A class used to handle the standard structure of an actor's state and events""" 12 | 13 | configure_logs() 14 | 15 | env: Optional[Environment] = Environment() 16 | state: Optional[Process] = None 17 | condition: str = '' 18 | 19 | def __post_init__(self): 20 | """Immediately after the actor is created, it starts idling""" 21 | 22 | self._log('Actor logged on') 23 | 24 | self.state = self.env.process(self._idle_state()) 25 | 26 | def _idle_state(self): 27 | """State that simulates the actor being idle and waiting for events""" 28 | 29 | yield 30 | 31 | def _log(self, msg: str): 32 | """Method to log detailed information of the actor's actions""" 33 | 34 | log(env=self.env, actor_name=self.__class__.__name__, condition=self.condition, msg=msg) 35 | -------------------------------------------------------------------------------- /actors/courier.py: -------------------------------------------------------------------------------- 1 | import random 2 | import time 3 | from dataclasses import dataclass, field 4 | from typing import List, Optional, Any, Dict 5 | 6 | from simpy import Interrupt, Event 7 | from simpy.events import NORMAL 8 | 9 | from actors.actor import Actor 10 | from objects.location import Location 11 | from objects.notification import Notification, NotificationType 12 | from objects.order import Order 13 | from objects.route import Route 14 | from objects.stop import Stop, StopType 15 | from objects.vehicle import Vehicle 16 | from policies.courier.acceptance.absolute import AbsoluteAcceptancePolicy 17 | from policies.courier.acceptance.courier_acceptance_policy import CourierAcceptancePolicy 18 | from policies.courier.acceptance.random_uniform import UniformAcceptancePolicy 19 | from policies.courier.movement.courier_movement_policy import CourierMovementPolicy 20 | from policies.courier.movement.osrm import OSRMMovementPolicy 21 | from policies.courier.movement_evaluation.courier_movement_evaluation_policy import CourierMovementEvaluationPolicy 22 | from policies.courier.movement_evaluation.geohash_neighbors import NeighborsMoveEvalPolicy 23 | from policies.courier.movement_evaluation.still import StillMoveEvalPolicy 24 | from settings import settings 25 | from utils.datetime_utils import sec_to_time, time_diff, sec_to_hour 26 | 27 | COURIER_ACCEPTANCE_POLICIES_MAP = { 28 | 'uniform': UniformAcceptancePolicy(), 29 | 'absolute': AbsoluteAcceptancePolicy() 30 | } 31 | COURIER_MOVEMENT_EVALUATION_POLICIES_MAP = { 32 | 'neighbors': NeighborsMoveEvalPolicy(), 33 | 'still': StillMoveEvalPolicy() 34 | } 35 | COURIER_MOVEMENT_POLICIES_MAP = { 36 | 'osrm': OSRMMovementPolicy() 37 | } 38 | 39 | 40 | @dataclass 41 | class Courier(Actor): 42 | """A class used to handle a courier's state and events""" 43 | 44 | dispatcher: Optional[Any] = None 45 | acceptance_policy: Optional[CourierAcceptancePolicy] = UniformAcceptancePolicy() 46 | movement_evaluation_policy: Optional[CourierMovementEvaluationPolicy] = NeighborsMoveEvalPolicy() 47 | movement_policy: Optional[CourierMovementPolicy] = OSRMMovementPolicy() 48 | 49 | acceptance_rate: float = random.uniform(settings.COURIER_MIN_ACCEPTANCE_RATE, 1) 50 | accepted_notifications: List[Notification] = field(default_factory=lambda: list()) 51 | active_route: Optional[Route] = None 52 | active_stop: Optional[Stop] = None 53 | courier_id: Optional[int] = None 54 | earnings: Optional[float] = None 55 | fulfilled_orders: List[int] = field(default_factory=lambda: list()) 56 | guaranteed_compensation: Optional[bool] = None 57 | location: Optional[Location] = None 58 | log_off_scheduled: Optional[bool] = False 59 | on_time: time = None 60 | off_time: time = None 61 | rejected_orders: List[int] = field(default_factory=lambda: list()) 62 | utilization_time: float = 0 63 | vehicle: Optional[Vehicle] = Vehicle.MOTORCYCLE 64 | 65 | def __post_init__(self): 66 | """Immediately after the courier logs on, the log off is scheduled and it starts idling""" 67 | 68 | self._log(f'Actor {self.courier_id} logged on') 69 | 70 | self._schedule_log_off_event() 71 | self.state = self.env.process(self._idle_state()) 72 | 73 | def _idle_state(self): 74 | """State that simulates a courier being idle / waiting""" 75 | 76 | self.condition = 'idle' 77 | 78 | self._log(f'Courier {self.courier_id} begins idling') 79 | 80 | try: 81 | self.dispatcher.courier_idle_event(courier=self) 82 | 83 | except Interrupt: 84 | pass 85 | 86 | while True: 87 | try: 88 | yield self.env.timeout(delay=settings.COURIER_WAIT_TO_MOVE) 89 | yield self.env.process(self._evaluate_movement_event()) 90 | 91 | except Interrupt: 92 | break 93 | 94 | def _moving_state(self, destination: Location): 95 | """State detailing how a courier moves to a destination""" 96 | 97 | self.condition = 'moving' 98 | state_start = sec_to_time(self.env.now) 99 | self.dispatcher.courier_moving_event(courier=self) 100 | yield self.env.process( 101 | self.movement_policy.execute( 102 | origin=self.location, 103 | destination=destination, 104 | env=self.env, 105 | courier=self 106 | ) 107 | ) 108 | self.utilization_time += time_diff(sec_to_time(self.env.now), state_start) 109 | 110 | def _picking_up_state(self, orders: Dict[int, Order]): 111 | """State that simulates a courier picking up stuff at the pick up location""" 112 | 113 | self.condition = 'picking_up' 114 | 115 | self._log(f'Courier {self.courier_id} begins pick up state') 116 | 117 | state_start = sec_to_time(self.env.now) 118 | 119 | try: 120 | self.dispatcher.courier_picking_up_event(courier=self) 121 | self.dispatcher.orders_in_store_event(orders) 122 | 123 | except Interrupt: 124 | pass 125 | 126 | try: 127 | service_time = max(order.pick_up_service_time for order in orders.values()) 128 | latest_ready_time = max(order.ready_time for order in orders.values()) 129 | waiting_time = time_diff(latest_ready_time, sec_to_time(self.env.now)) 130 | yield self.env.timeout(delay=service_time + max(0, waiting_time)) 131 | 132 | except Interrupt: 133 | pass 134 | 135 | self.utilization_time += time_diff(sec_to_time(self.env.now), state_start) 136 | 137 | self._log(f'Courier {self.courier_id} finishes pick up state') 138 | 139 | self.dispatcher.orders_picked_up_event(orders) 140 | 141 | def _dropping_off_state(self, orders: Dict[int, Order]): 142 | """State that simulates a courier dropping off stuff at the drop off location""" 143 | 144 | self.condition = 'dropping_off' 145 | 146 | self._log(f'Courier {self.courier_id} begins drop off state of orders {list(orders.keys())}') 147 | 148 | state_start = sec_to_time(self.env.now) 149 | self.dispatcher.courier_dropping_off_event(courier=self) 150 | service_time = max(order.drop_off_service_time for order in orders.values()) 151 | yield self.env.timeout(delay=service_time) 152 | self.utilization_time += time_diff(sec_to_time(self.env.now), state_start) 153 | 154 | self._log(f'Courier {self.courier_id} finishes drop off state of orders {list(orders.keys())}') 155 | 156 | self.dispatcher.orders_dropped_off_event(orders=orders, courier=self) 157 | 158 | def _evaluate_movement_event(self): 159 | """Event detailing how a courier evaluates to move about the city""" 160 | 161 | destination = self.movement_evaluation_policy.execute(current_location=self.location) 162 | 163 | if destination is not None: 164 | 165 | self._log(f'Courier {self.courier_id} decided to move from {self.location} to {destination}') 166 | 167 | yield self.env.process(self._moving_state(destination)) 168 | 169 | self._log(f'Courier {self.courier_id} finished relocating and is now at {self.location}') 170 | 171 | self.condition = 'idle' 172 | self.dispatcher.courier_idle_event(courier=self) 173 | 174 | else: 175 | self._log(f'Courier {self.courier_id} decided not to move') 176 | 177 | def notification_event(self, notification: Notification): 178 | """Event detailing how a courier handles a notification""" 179 | 180 | self._log(f'Courier {self.courier_id} received a {notification.type.label} notification') 181 | 182 | if self.condition in ['idle', 'picking_up']: 183 | try: 184 | self.state.interrupt() 185 | 186 | except: 187 | pass 188 | 189 | accepts_notification = yield self.env.process( 190 | self.acceptance_policy.execute(self.acceptance_rate, self.env) 191 | ) 192 | 193 | if accepts_notification: 194 | self._log(f'Courier {self.courier_id} accepted a {notification.type.label} notification.') 195 | 196 | self.dispatcher.notification_accepted_event(notification=notification, courier=self) 197 | 198 | if ( 199 | (isinstance(notification.instruction, list) or bool(notification.instruction.orders)) and 200 | self.active_route is not None 201 | ) or notification.type == NotificationType.PREPOSITIONING: 202 | self.env.process(self._execute_active_route()) 203 | 204 | else: 205 | self.state = self.env.process(self._idle_state()) 206 | 207 | else: 208 | self._log(f'Courier {self.courier_id} rejected a {notification.type.label} notification.') 209 | 210 | self.dispatcher.notification_rejected_event(notification=notification, courier=self) 211 | 212 | if self.condition == 'idle': 213 | self.state = self.env.process(self._idle_state()) 214 | 215 | elif self.condition == 'picking_up': 216 | self.env.process(self._execute_active_route()) 217 | 218 | def log_off_event(self): 219 | """Event detailing how a courier logs off of the system, ensuring earnings are calculated""" 220 | 221 | self._log(f'Courier {self.courier_id} is going to log off') 222 | 223 | if self.active_route is None and self.active_stop is None: 224 | self.earnings = self._calculate_earnings() 225 | 226 | try: 227 | self.state.interrupt() 228 | 229 | except: 230 | pass 231 | 232 | self.condition = 'logged_off' 233 | self.dispatcher.courier_log_off_event(courier=self) 234 | 235 | else: 236 | self.log_off_scheduled = True 237 | 238 | self._log(f'Courier {self.courier_id} is scheduled to log off after completing current instructions') 239 | 240 | def _execute_stop(self, stop: Stop): 241 | """State to execute a stop""" 242 | 243 | self.active_stop = stop 244 | 245 | self._log( 246 | f'Courier {self.courier_id} is at stop of type {self.active_stop.type.label} ' 247 | f'with orders {list(stop.orders.keys())}, on location {stop.location}' 248 | ) 249 | 250 | service_state = self._picking_up_state if stop.type == StopType.PICK_UP else self._dropping_off_state 251 | yield self.env.process(service_state(orders=stop.orders)) 252 | 253 | stop.visited = True 254 | 255 | def _execute_active_route(self): 256 | """State to execute the remainder of a route""" 257 | 258 | stops_remaining = [stop for stop in self.active_route.stops if not stop.visited] 259 | 260 | self._log(f'Courier {self.courier_id} has {len(stops_remaining)} stops remaining') 261 | 262 | for stop in stops_remaining: 263 | if self.active_stop != stop: 264 | self._log(f'Courier {self.courier_id} will move to next stop') 265 | 266 | yield self.env.process(self._moving_state(destination=stop.location)) 267 | 268 | if stop.type != StopType.PREPOSITION: 269 | yield self.env.process(self._execute_stop(stop)) 270 | 271 | self.active_route = None 272 | self.active_stop = None 273 | 274 | self._log(f'Courier {self.courier_id} finishes route execution') 275 | 276 | if self.log_off_scheduled: 277 | self.log_off_event() 278 | 279 | else: 280 | self.state = self.env.process(self._idle_state()) 281 | 282 | def _log_off_callback(self, event: Event): 283 | """Callback to activate the log off event""" 284 | 285 | self.log_off_event() 286 | event.succeed() 287 | event.callbacks = [] 288 | 289 | def _schedule_log_off_event(self): 290 | """Method that allows the courier to schedule the log off time""" 291 | 292 | log_off_event = Event(env=self.env) 293 | log_off_event.callbacks.append(self._log_off_callback) 294 | log_off_delay = time_diff(self.off_time, self.on_time) 295 | self.env.schedule(event=log_off_event, priority=NORMAL, delay=log_off_delay) 296 | 297 | def _calculate_earnings(self) -> float: 298 | """Method to calculate earnings after the shift ends""" 299 | 300 | delivery_earnings = len(self.fulfilled_orders) * settings.COURIER_EARNINGS_PER_ORDER 301 | guaranteed_earnings = sec_to_hour(time_diff(self.off_time, self.on_time)) * settings.COURIER_EARNINGS_PER_HOUR 302 | 303 | if guaranteed_earnings > delivery_earnings > 0: 304 | self.guaranteed_compensation = True 305 | earnings = guaranteed_earnings 306 | 307 | else: 308 | self.guaranteed_compensation = False 309 | earnings = delivery_earnings 310 | 311 | self._log( 312 | f'Courier {self.courier_id} received earnings of ${round(earnings, 2)} ' 313 | f'for {len(self.fulfilled_orders)} orders during the complete shift' 314 | ) 315 | 316 | return earnings 317 | 318 | def calculate_metrics(self) -> Dict[str, Any]: 319 | """Method to calculate the metrics of a courier""" 320 | 321 | courier_delivery_earnings = len(self.fulfilled_orders) * settings.COURIER_EARNINGS_PER_ORDER 322 | shift_duration = time_diff(self.off_time, self.on_time) 323 | 324 | if shift_duration > 0: 325 | courier_utilization = self.utilization_time / shift_duration 326 | courier_orders_delivered_per_hour = len(self.fulfilled_orders) / sec_to_hour(shift_duration) 327 | courier_bundles_picked_per_hour = len(self.accepted_notifications) / sec_to_hour(shift_duration) 328 | 329 | else: 330 | courier_utilization, courier_orders_delivered_per_hour, courier_bundles_picked_per_hour = 0, 0, 0 331 | 332 | return { 333 | 'courier_id': self.courier_id, 334 | 'on_time': self.on_time, 335 | 'off_time': self.off_time, 336 | 'fulfilled_orders': len(self.fulfilled_orders), 337 | 'earnings': self.earnings, 338 | 'utilization_time': self.utilization_time, 339 | 'accepted_notifications': len(self.accepted_notifications), 340 | 'guaranteed_compensation': self.guaranteed_compensation, 341 | 'courier_utilization': courier_utilization, 342 | 'courier_delivery_earnings': courier_delivery_earnings, 343 | 'courier_compensation': self.earnings, 344 | 'courier_orders_delivered_per_hour': courier_orders_delivered_per_hour, 345 | 'courier_bundles_picked_per_hour': courier_bundles_picked_per_hour 346 | } 347 | -------------------------------------------------------------------------------- /actors/dispatcher.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass, field 2 | from datetime import time 3 | from typing import Dict, Optional, List, Tuple 4 | 5 | from simpy import Interrupt, Event 6 | from simpy.events import NORMAL 7 | 8 | from actors.actor import Actor 9 | from actors.courier import Courier 10 | from objects.matching_metric import MatchingMetric 11 | from objects.notification import Notification, NotificationType 12 | from objects.order import Order 13 | from objects.route import Route 14 | from objects.stop import Stop 15 | from policies.dispatcher.buffering.dispatcher_buffering_policy import DispatcherBufferingPolicy 16 | from policies.dispatcher.buffering.rolling_horizon import RollingBufferingPolicy 17 | from policies.dispatcher.cancellation.dispatcher_cancellation_policy import DispatcherCancellationPolicy 18 | from policies.dispatcher.cancellation.static import StaticCancellationPolicy 19 | from policies.dispatcher.matching.dispatcher_matching_policy import DispatcherMatchingPolicy 20 | from policies.dispatcher.matching.greedy import GreedyMatchingPolicy 21 | from policies.dispatcher.matching.myopic import MyopicMatchingPolicy 22 | from policies.dispatcher.prepositioning.dispatcher_prepositioning_policy import DispatcherPrepositioningPolicy 23 | from policies.dispatcher.prepositioning.naive import NaivePrepositioningPolicy 24 | from policies.dispatcher.prepositioning_evaluation.dispatcher_prepositioning_evaluation_policy import \ 25 | DispatcherPrepositioningEvaluationPolicy 26 | from policies.dispatcher.prepositioning_evaluation.fixed import FixedPrepositioningEvaluationPolicy 27 | from settings import settings 28 | from utils.datetime_utils import sec_to_time, time_diff, time_add 29 | 30 | DISPATCHER_CANCELLATION_POLICIES_MAP = { 31 | 'static': StaticCancellationPolicy() 32 | } 33 | DISPATCHER_BUFFERING_POLICIES_MAP = { 34 | 'rolling_horizon': RollingBufferingPolicy() 35 | } 36 | DISPATCHER_MATCHING_POLICIES_MAP = { 37 | 'greedy': GreedyMatchingPolicy(), 38 | 'mdrp': MyopicMatchingPolicy( 39 | assignment_updates=False, 40 | prospects=False, 41 | notification_filtering=True, 42 | mip_matcher=True 43 | ), 44 | 'mdrp_graph': MyopicMatchingPolicy( 45 | assignment_updates=False, 46 | prospects=False, 47 | notification_filtering=True, 48 | mip_matcher=False 49 | ), 50 | 'mdrp_graph_prospects': MyopicMatchingPolicy( 51 | assignment_updates=False, 52 | prospects=True, 53 | notification_filtering=True, 54 | mip_matcher=False 55 | ), 56 | 'modified_mdrp': MyopicMatchingPolicy( 57 | assignment_updates=True, 58 | prospects=True, 59 | notification_filtering=True, 60 | mip_matcher=False 61 | ), 62 | } 63 | DISPATCHER_PREPOSITIONING_POLICIES_MAP = { 64 | 'naive': NaivePrepositioningPolicy() 65 | } 66 | DISPATCHER_PREPOSITIONING_EVALUATION_POLICIES_MAP = { 67 | 'fixed': FixedPrepositioningEvaluationPolicy() 68 | } 69 | 70 | 71 | @dataclass 72 | class Dispatcher(Actor): 73 | """A class used to handle a dispatcher's state and events""" 74 | 75 | cancellation_policy: Optional[DispatcherCancellationPolicy] = StaticCancellationPolicy() 76 | buffering_policy: Optional[DispatcherBufferingPolicy] = RollingBufferingPolicy() 77 | matching_policy: Optional[DispatcherMatchingPolicy] = GreedyMatchingPolicy() 78 | prepositioning_policy: Optional[DispatcherPrepositioningPolicy] = NaivePrepositioningPolicy() 79 | prepositioning_evaluation_policy: Optional[ 80 | DispatcherPrepositioningEvaluationPolicy 81 | ] = FixedPrepositioningEvaluationPolicy() 82 | 83 | assigned_orders: Dict[int, Order] = field(default_factory=lambda: dict()) 84 | canceled_orders: Dict[int, Order] = field(default_factory=lambda: dict()) 85 | fulfilled_orders: Dict[int, Order] = field(default_factory=lambda: dict()) 86 | placed_orders: Dict[int, Tuple[time, Order]] = field(default_factory=lambda: dict()) 87 | scheduled_cancellation_evaluation_orders: Dict[int, Tuple[time, Order]] = field(default_factory=lambda: dict()) 88 | unassigned_orders: Dict[int, Order] = field(default_factory=lambda: dict()) 89 | 90 | dropping_off_couriers: Dict[int, Courier] = field(default_factory=lambda: dict()) 91 | idle_couriers: Dict[int, Courier] = field(default_factory=lambda: dict()) 92 | logged_off_couriers: Dict[int, Courier] = field(default_factory=lambda: dict()) 93 | moving_couriers: Dict[int, Courier] = field(default_factory=lambda: dict()) 94 | picking_up_couriers: Dict[int, Courier] = field(default_factory=lambda: dict()) 95 | 96 | matching_metrics: List[MatchingMetric] = field(default_factory=lambda: list()) 97 | notifications: List[Notification] = field(default_factory=lambda: list()) 98 | 99 | def _idle_state(self): 100 | """State that simulates the dispatcher listening for events""" 101 | 102 | self.condition = 'listening' 103 | 104 | self._log('Dispatcher is listening') 105 | 106 | while True: 107 | try: 108 | self._evaluate_buffering_event() 109 | self._evaluate_prepositioning_event() 110 | yield self.env.timeout(delay=1) 111 | 112 | except Interrupt: 113 | break 114 | 115 | def order_submitted_event(self, order: Order, preparation_time: time, ready_time: time): 116 | """Event detailing how the dispatcher handles the submission of a new order""" 117 | 118 | self._log(f'Dispatcher received the order {order.order_id} and moved it to the placed orders') 119 | 120 | order.preparation_time = preparation_time 121 | order.ready_time = ready_time 122 | self.placed_orders[order.order_id] = (preparation_time, order) 123 | self.scheduled_cancellation_evaluation_orders[order.order_id] = ( 124 | time_add(preparation_time, settings.DISPATCHER_WAIT_TO_CANCEL), 125 | order 126 | ) 127 | self._schedule_buffer_order_event(order) 128 | 129 | def _buffer_order_event(self): 130 | """Event detailing how the dispatcher buffers an order""" 131 | 132 | orders_for_buffering = [ 133 | order 134 | for order_id, (scheduled_time, order) in self.placed_orders.items() 135 | if sec_to_time(self.env.now) >= scheduled_time 136 | ] 137 | 138 | for order in orders_for_buffering: 139 | del self.placed_orders[order.order_id] 140 | self.unassigned_orders[order.order_id] = order 141 | 142 | self._log(f'Dispatcher has moved the order {order.order_id} to the unassigned buffer') 143 | 144 | def _evaluate_cancellation_event(self): 145 | """Event detailing how the dispatcher evaluates if it should cancel an order""" 146 | 147 | orders_for_evaluation = [ 148 | order 149 | for order_id, (scheduled_time, order) in self.scheduled_cancellation_evaluation_orders.items() 150 | if sec_to_time(self.env.now) >= scheduled_time 151 | ] 152 | 153 | for order in orders_for_evaluation: 154 | should_cancel = self.cancellation_policy.execute(courier_id=order.courier_id) 155 | 156 | if should_cancel: 157 | self._log(f'Dispatcher decided to cancel the order {order.order_id}') 158 | self.cancel_order_event(order) 159 | 160 | else: 161 | self._log(f'Dispatcher decided not to cancel the order {order.order_id}') 162 | 163 | def cancel_order_event(self, order: Order): 164 | """Event detailing how the dispatcher handles a user canceling an order""" 165 | 166 | if ( 167 | order.state != 'canceled' and 168 | ( 169 | order.order_id in self.placed_orders.keys() or 170 | order.order_id in self.unassigned_orders.keys() 171 | 172 | ) and 173 | order.order_id not in self.canceled_orders.keys() and 174 | order.order_id not in self.assigned_orders.keys() and 175 | order.order_id not in self.fulfilled_orders.keys() 176 | ): 177 | if order.order_id in self.placed_orders.keys(): 178 | del self.placed_orders[order.order_id] 179 | 180 | if order.order_id in self.unassigned_orders.keys(): 181 | del self.unassigned_orders[order.order_id] 182 | 183 | order.cancellation_time = sec_to_time(self.env.now) 184 | order.state = 'canceled' 185 | order.user.condition = 'canceled' 186 | self.canceled_orders[order.order_id] = order 187 | 188 | self._log(f'Dispatcher canceled the order {order.order_id}') 189 | 190 | def _evaluate_buffering_event(self): 191 | """Event detailing how the dispatcher evaluates if it should flush the buffer and begin dispatching""" 192 | 193 | if self.buffering_policy.execute(env_time=self.env.now): 194 | self._log('Buffering time fulfilled, begin dispatch event.') 195 | 196 | self._dispatch_event() 197 | 198 | def _dispatch_event(self): 199 | """Event detailing how the dispatcher executes the dispatch instructions: routing & matching""" 200 | 201 | orders = self.unassigned_orders.values() 202 | couriers = { 203 | courier.courier_id: courier 204 | for courier in {**self.idle_couriers, **self.picking_up_couriers}.values() 205 | if ( 206 | len(courier.active_route.orders) < settings.DISPATCHER_PROSPECTS_MAX_ORDERS 207 | if courier.active_route is not None 208 | else True 209 | ) 210 | } 211 | self._log(f'Attempting dispatch of {len(orders)} orders and {len(couriers)} couriers.') 212 | 213 | if bool(orders) and bool(couriers): 214 | notifications, matching_metric = self.matching_policy.execute( 215 | orders=list(orders), 216 | couriers=list(couriers.values()), 217 | env_time=self.env.now 218 | ) 219 | notifications_log = [ 220 | ([order_id for order_id in notification.instruction.orders.keys()], notification.courier.courier_id) 221 | for notification in notifications 222 | ] 223 | self._log( 224 | f'Dispatcher will send {len(notifications)} notifications ([order_id\'s], courier_id): ' 225 | f'{notifications_log}' 226 | ) 227 | 228 | for notification in notifications: 229 | if notification.instruction is not None and notification.courier is not None: 230 | self.notifications.append(notification) 231 | self.matching_metrics.append(matching_metric) 232 | self.env.process(couriers[notification.courier.courier_id].notification_event(notification)) 233 | 234 | def _prepositioning_event(self): 235 | """Event detailing how the dispatcher executes the preposition instructions, sending them to couriers""" 236 | 237 | self._log(f'Prepositioning time fulfilled, attempting prepositioning of {len(self.idle_couriers)} couriers') 238 | 239 | notifications = self.prepositioning_policy.execute( 240 | orders=[order for _, order in self.placed_orders.values()], 241 | couriers=self.idle_couriers.values() 242 | ) 243 | if bool(notifications): 244 | self._log(f'Dispatcher will send {len(notifications)} prepositioning notifications') 245 | 246 | for notification in notifications: 247 | if notification.instruction is not None and notification.courier is not None: 248 | notification.type = NotificationType.PREPOSITIONING 249 | self.notifications.append(notification) 250 | self.env.process( 251 | self.idle_couriers[notification.courier.courier_id].notification_event(notification) 252 | ) 253 | 254 | def _evaluate_prepositioning_event(self): 255 | """Event detailing how the dispatcher evaluates if it should flush the buffer and begin dispatching""" 256 | 257 | if self.prepositioning_evaluation_policy.execute(env_time=self.env.now): 258 | self._prepositioning_event() 259 | self._log('Prepositioning time fulfilled, begin prepositioning event.') 260 | 261 | self._prepositioning_event() 262 | 263 | def notification_accepted_event(self, notification: Notification, courier: Courier): 264 | """Event detailing how the dispatcher handles the acceptance of a notification by a courier""" 265 | 266 | self._log( 267 | f'Dispatcher will handle acceptance of a {notification.type.label} notification ' 268 | f'from courier {courier.courier_id} (condition = {courier.condition})' 269 | ) 270 | 271 | if notification.type == NotificationType.PREPOSITIONING: 272 | courier.active_route = notification.instruction 273 | 274 | elif notification.type == NotificationType.PICK_UP_DROP_OFF: 275 | order_ids = ( 276 | list(notification.instruction.orders.keys()) 277 | if isinstance(notification.instruction, Route) 278 | else [order_id for stop in notification.instruction for order_id in stop.orders.keys()] 279 | ) 280 | processed_order_ids = [ 281 | order_id 282 | for order_id in order_ids 283 | if ( 284 | order_id in self.canceled_orders.keys() or 285 | order_id in self.assigned_orders.keys() or 286 | order_id in self.fulfilled_orders.keys() 287 | ) 288 | ] 289 | 290 | if bool(processed_order_ids): 291 | self._log( 292 | f'Dispatcher will update the notification to courier {courier.courier_id} ' 293 | f'based on these orders being already processed: {processed_order_ids}' 294 | ) 295 | notification.update(processed_order_ids) 296 | 297 | if ( 298 | ( 299 | isinstance(notification.instruction, Route) and 300 | bool(notification.instruction.orders) and 301 | bool(notification.instruction.stops) 302 | ) or 303 | ( 304 | isinstance(notification.instruction, list) and 305 | bool(notification.instruction) and 306 | bool(notification.instruction[0].orders) 307 | ) 308 | ): 309 | order_ids = ( 310 | list(notification.instruction.orders.keys()) 311 | if isinstance(notification.instruction, Route) 312 | else [order_id for stop in notification.instruction for order_id in stop.orders.keys()] 313 | ) 314 | self._log( 315 | f'Dispatcher will handle acceptance of orders {order_ids} ' 316 | f'from courier {courier.courier_id} (condition = {courier.condition}). ' 317 | f'Instruction is a {"Route" if isinstance(notification.instruction, Route) else "List[Stop]"}' 318 | ) 319 | 320 | instruction_orders = ( 321 | notification.instruction.orders.items() 322 | if isinstance(notification.instruction, Route) 323 | else [ 324 | (order_id, order) 325 | for stop in notification.instruction for order_id, order in stop.orders.items() 326 | ] 327 | ) 328 | for order_id, order in instruction_orders: 329 | del self.unassigned_orders[order_id] 330 | order.acceptance_time = sec_to_time(self.env.now) 331 | order.state = 'in_progress' 332 | order.courier_id = courier.courier_id 333 | self.assigned_orders[order_id] = order 334 | 335 | if courier.condition == 'idle' and isinstance(notification.instruction, Route): 336 | courier.active_route = notification.instruction 337 | 338 | elif courier.condition == 'picking_up' and isinstance(notification.instruction, list): 339 | for stop in notification.instruction: 340 | for order_id, order in stop.orders.items(): 341 | courier.active_route.orders[order_id] = order 342 | courier.active_stop.orders[order_id] = order 343 | 344 | courier.active_route.stops.append( 345 | Stop( 346 | location=stop.location, 347 | position=len(courier.active_route.stops), 348 | orders=stop.orders, 349 | type=stop.type 350 | ) 351 | ) 352 | 353 | courier.accepted_notifications.append(notification) 354 | 355 | else: 356 | self._log( 357 | f'Dispatcher will nullify notification to courier {courier.courier_id}. All orders canceled.' 358 | ) 359 | 360 | def notification_rejected_event(self, notification: Notification, courier: Courier): 361 | """Event detailing how the dispatcher handles the rejection of a notification""" 362 | 363 | self._log( 364 | f'Dispatcher will handle rejection of a {notification.type.label} notification ' 365 | f'from courier {courier.courier_id} (condition = {courier.condition})' 366 | ) 367 | 368 | if notification.type == NotificationType.PICK_UP_DROP_OFF: 369 | self._log( 370 | f'Dispatcher will handle rejection of orders {list(notification.instruction.orders.keys())} ' 371 | f'from courier {courier.courier_id} (condition = {courier.condition}). ' 372 | f'Instruction is a {"Route" if isinstance(notification.instruction, Route) else "Stop"}' 373 | ) 374 | 375 | for order_id, order in notification.instruction.orders.items(): 376 | order.rejected_by.append(courier.courier_id) 377 | courier.rejected_orders.append(order_id) 378 | 379 | def orders_in_store_event(self, orders: Dict[int, Order]): 380 | """Event detailing how the dispatcher handles a courier arriving to the store""" 381 | 382 | self._log(f'Dispatcher will set these orders to be in store: {list(orders.keys())}') 383 | 384 | for order_id, order in orders.items(): 385 | order.in_store_time = sec_to_time(self.env.now) 386 | order.state = 'in_store' 387 | 388 | def orders_picked_up_event(self, orders: Dict[int, Order]): 389 | """Event detailing how the dispatcher handles a courier picking up an order""" 390 | 391 | self._log(f'Dispatcher will set these orders to be picked up: {list(orders.keys())}') 392 | 393 | for order_id, order in orders.items(): 394 | order.pick_up_time = sec_to_time(self.env.now) 395 | order.state = 'picked_up' 396 | 397 | def orders_dropped_off_event(self, orders: Dict[int, Order], courier: Courier): 398 | """Event detailing how the dispatcher handles the fulfillment of an order""" 399 | 400 | self._log( 401 | f'Dispatcher will set these orders to be dropped off: {list(orders.keys())}; ' 402 | f'by courier {courier.courier_id}' 403 | ) 404 | 405 | for order_id, order in orders.items(): 406 | if order_id not in self.fulfilled_orders.keys() and order_id in self.assigned_orders.keys(): 407 | del self.assigned_orders[order_id] 408 | order.drop_off_time = sec_to_time(self.env.now) 409 | order.state = 'dropped_off' 410 | self.fulfilled_orders[order_id] = order 411 | courier.fulfilled_orders.append(order_id) 412 | order.user.order_dropped_off_event(order_id) 413 | 414 | def courier_idle_event(self, courier: Courier): 415 | """Event detailing how the dispatcher handles setting a courier to idle""" 416 | 417 | self._log(f'Dispatcher will set courier {courier.courier_id} to idle') 418 | 419 | if courier.courier_id in self.dropping_off_couriers.keys(): 420 | del self.dropping_off_couriers[courier.courier_id] 421 | 422 | elif courier.courier_id in self.moving_couriers.keys(): 423 | del self.moving_couriers[courier.courier_id] 424 | 425 | elif courier.courier_id in self.picking_up_couriers.keys(): 426 | del self.picking_up_couriers[courier.courier_id] 427 | 428 | self.idle_couriers[courier.courier_id] = courier 429 | 430 | def courier_moving_event(self, courier: Courier): 431 | """Event detailing how the dispatcher handles setting a courier to dropping off""" 432 | 433 | self._log(f'Dispatcher will set courier {courier.courier_id} to moving') 434 | 435 | if courier.courier_id in self.idle_couriers.keys(): 436 | del self.idle_couriers[courier.courier_id] 437 | 438 | elif courier.courier_id in self.dropping_off_couriers.keys(): 439 | del self.dropping_off_couriers[courier.courier_id] 440 | 441 | elif courier.courier_id in self.picking_up_couriers.keys(): 442 | del self.picking_up_couriers[courier.courier_id] 443 | 444 | self.moving_couriers[courier.courier_id] = courier 445 | 446 | def courier_picking_up_event(self, courier: Courier): 447 | """Event detailing how the dispatcher handles setting a courier to picking up""" 448 | 449 | self._log(f'Dispatcher will set courier {courier.courier_id} to picking up') 450 | 451 | if courier.courier_id in self.idle_couriers.keys(): 452 | del self.idle_couriers[courier.courier_id] 453 | 454 | elif courier.courier_id in self.dropping_off_couriers.keys(): 455 | del self.dropping_off_couriers[courier.courier_id] 456 | 457 | elif courier.courier_id in self.moving_couriers.keys(): 458 | del self.moving_couriers[courier.courier_id] 459 | 460 | self.picking_up_couriers[courier.courier_id] = courier 461 | 462 | def courier_dropping_off_event(self, courier: Courier): 463 | """Event detailing how the dispatcher handles setting a courier to dropping off""" 464 | 465 | self._log(f'Dispatcher will set courier {courier.courier_id} to dropping off') 466 | 467 | if courier.courier_id in self.idle_couriers.keys(): 468 | del self.idle_couriers[courier.courier_id] 469 | 470 | elif courier.courier_id in self.moving_couriers.keys(): 471 | del self.moving_couriers[courier.courier_id] 472 | 473 | elif courier.courier_id in self.picking_up_couriers.keys(): 474 | del self.picking_up_couriers[courier.courier_id] 475 | 476 | self.dropping_off_couriers[courier.courier_id] = courier 477 | 478 | def courier_log_off_event(self, courier: Courier): 479 | """Event detailing how the dispatcher handles when a courier wants to log off""" 480 | 481 | self._log(f'Dispatcher will set courier {courier.courier_id} to logged off') 482 | 483 | if courier.courier_id in self.idle_couriers.keys(): 484 | del self.idle_couriers[courier.courier_id] 485 | 486 | elif courier.courier_id in self.dropping_off_couriers.keys(): 487 | del self.dropping_off_couriers[courier.courier_id] 488 | 489 | elif courier.courier_id in self.moving_couriers.keys(): 490 | del self.moving_couriers[courier.courier_id] 491 | 492 | elif courier.courier_id in self.picking_up_couriers.keys(): 493 | del self.picking_up_couriers[courier.courier_id] 494 | 495 | self.logged_off_couriers[courier.courier_id] = courier 496 | 497 | def _schedule_evaluate_cancellation_event(self): 498 | """Method that allows the dispatcher to schedule the cancellation evaluation event""" 499 | 500 | evaluate_cancellation_event = Event(env=self.env) 501 | evaluate_cancellation_event.callbacks.append(self._evaluate_cancellation_callback) 502 | self.env.schedule(event=evaluate_cancellation_event, priority=NORMAL, delay=settings.DISPATCHER_WAIT_TO_CANCEL) 503 | 504 | def _schedule_buffer_order_event(self, order: Order): 505 | """Method that allows the dispatcher to schedule the order buffering event""" 506 | 507 | buffering_event = Event(env=self.env) 508 | buffering_event.callbacks.append(self._buffer_order_callback) 509 | self.env.schedule( 510 | event=buffering_event, 511 | priority=NORMAL, 512 | delay=time_diff(order.preparation_time, order.placement_time) 513 | ) 514 | 515 | def _evaluate_cancellation_callback(self, event: Event): 516 | """Callback detailing how the dispatcher evaluates canceling an order""" 517 | 518 | self._evaluate_cancellation_event() 519 | 520 | event.succeed() 521 | event.callbacks = [] 522 | 523 | def _buffer_order_callback(self, event: Event): 524 | """Callback detailing how the dispatcher buffers an order once it's placed""" 525 | 526 | self._buffer_order_event() 527 | self._schedule_evaluate_cancellation_event() 528 | 529 | event.succeed() 530 | event.callbacks = [] 531 | -------------------------------------------------------------------------------- /actors/user.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from datetime import time 3 | from typing import Optional 4 | 5 | from simpy import Interrupt, Event 6 | from simpy.events import NORMAL 7 | 8 | from actors.actor import Actor 9 | from actors.dispatcher import Dispatcher 10 | from objects.location import Location 11 | from objects.order import Order 12 | from policies.user.cancellation.random import RandomCancellationPolicy 13 | from policies.user.cancellation.user_cancellation_policy import UserCancellationPolicy 14 | from settings import settings 15 | 16 | USER_CANCELLATION_POLICIES_MAP = { 17 | 'random': RandomCancellationPolicy() 18 | } 19 | 20 | 21 | @dataclass 22 | class User(Actor): 23 | """A class used to handle a user's state and events""" 24 | 25 | dispatcher: Optional[Dispatcher] = None 26 | cancellation_policy: Optional[UserCancellationPolicy] = RandomCancellationPolicy() 27 | 28 | order: Optional[Order] = None 29 | user_id: Optional[int] = None 30 | 31 | def __post_init__(self): 32 | """Immediately after the actor is created, it starts idling""" 33 | 34 | self._log(f'Actor {self.user_id} logged on') 35 | 36 | self.state = self.env.process(self._idle_state()) 37 | 38 | def _idle_state(self): 39 | """State that simulates a user being idle""" 40 | 41 | self.condition = 'idle' 42 | 43 | self._log(f'New user begins idling') 44 | 45 | while True: 46 | try: 47 | yield self.env.timeout(delay=1) 48 | 49 | except Interrupt: 50 | break 51 | 52 | def _waiting_state(self): 53 | """State simulating the user is waiting for the order""" 54 | 55 | self.condition = 'waiting' 56 | 57 | self._log(f'User with order {self.order.order_id} begins waiting') 58 | 59 | while True: 60 | try: 61 | yield self.env.timeout(delay=1) 62 | 63 | except Interrupt: 64 | break 65 | 66 | def submit_order_event( 67 | self, 68 | order_id: int, 69 | pick_up_at: Location, 70 | drop_off_at: Location, 71 | placement_time: time, 72 | expected_drop_off_time: time, 73 | preparation_time: time, 74 | ready_time: time 75 | ): 76 | """Event detailing how a user submits an order""" 77 | 78 | order = Order( 79 | order_id=order_id, 80 | pick_up_at=pick_up_at, 81 | drop_off_at=drop_off_at, 82 | placement_time=placement_time, 83 | expected_drop_off_time=expected_drop_off_time, 84 | user=self 85 | ) 86 | self.order = order 87 | 88 | self._log(f'The user submitted the order {order.order_id}') 89 | 90 | self.state.interrupt() 91 | self.state = self.env.process(self._waiting_state()) 92 | self.dispatcher.order_submitted_event(order, preparation_time, ready_time) 93 | self._schedule_evaluate_cancellation_event() 94 | 95 | def _evaluate_cancellation_event(self): 96 | """Event detailing how a user decides to cancel an order""" 97 | 98 | should_cancel = self.cancellation_policy.execute(courier_id=self.order.courier_id) 99 | 100 | if should_cancel: 101 | self._cancel_order_event() 102 | 103 | else: 104 | self._log(f'The user decided not to cancel the order {self.order.order_id}') 105 | 106 | def _cancel_order_event(self): 107 | """Event detailing how the user cancels the order""" 108 | 109 | self._log(f'The user decided to cancel the order {self.order.order_id}') 110 | 111 | self.dispatcher.cancel_order_event(order=self.order) 112 | self.state.interrupt() 113 | 114 | def order_dropped_off_event(self, order_id: int): 115 | """Event detailing how the user gets the order delivered""" 116 | 117 | self._log(f'The user has the order {order_id} dropped off.') 118 | 119 | self.state.interrupt() 120 | self.condition = 'dropped_off' 121 | 122 | def _schedule_evaluate_cancellation_event(self): 123 | """Method that allows the user to schedule the cancellation evaluation event""" 124 | 125 | cancellation_event = Event(env=self.env) 126 | cancellation_event.callbacks.append(self._evaluate_cancellation_callback) 127 | self.env.schedule(event=cancellation_event, priority=NORMAL, delay=settings.USER_WAIT_TO_CANCEL) 128 | 129 | def _evaluate_cancellation_callback(self, event: Event): 130 | """Callback dto activate de cancellation evaluation eventr""" 131 | 132 | self._evaluate_cancellation_event() 133 | event.succeed() 134 | event.callbacks = [] 135 | -------------------------------------------------------------------------------- /actors/world.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from dataclasses import dataclass, field 3 | from datetime import time 4 | from os import system 5 | from typing import List, Dict, Any, Optional 6 | 7 | import pandas as pd 8 | from simpy import Environment, Process 9 | from sqlalchemy import create_engine 10 | from sqlalchemy.engine import Engine 11 | 12 | from actors.courier import Courier, COURIER_ACCEPTANCE_POLICIES_MAP, COURIER_MOVEMENT_EVALUATION_POLICIES_MAP, \ 13 | COURIER_MOVEMENT_POLICIES_MAP 14 | from actors.dispatcher import Dispatcher, DISPATCHER_CANCELLATION_POLICIES_MAP, DISPATCHER_BUFFERING_POLICIES_MAP, \ 15 | DISPATCHER_MATCHING_POLICIES_MAP, DISPATCHER_PREPOSITIONING_POLICIES_MAP, \ 16 | DISPATCHER_PREPOSITIONING_EVALUATION_POLICIES_MAP 17 | from actors.user import User, USER_CANCELLATION_POLICIES_MAP 18 | from ddbb.config import get_db_url 19 | from ddbb.queries.couriers_instance_data_query import couriers_query 20 | from ddbb.queries.orders_instance_data_query import orders_query 21 | from objects.location import Location 22 | from objects.vehicle import Vehicle 23 | from settings import settings 24 | from utils.datetime_utils import sec_to_time, time_to_query_format, time_add 25 | from utils.logging_utils import world_log 26 | 27 | 28 | @dataclass 29 | class World: 30 | """A class to handle the simulated world""" 31 | 32 | env: Environment 33 | instance: int 34 | connection: Optional[Engine] = None 35 | couriers: List[Courier] = field(default_factory=lambda: list()) 36 | dispatcher: Optional[Dispatcher] = None 37 | users: List[User] = field(default_factory=lambda: list()) 38 | state: Optional[Process] = None 39 | 40 | def __post_init__(self): 41 | """ 42 | The world is instantiated along with the single dispatcher and the DDBB connection. 43 | The World begins simulating immediately after it is created. 44 | """ 45 | 46 | logging.info(f'Instance {self.instance} | Simulation started at sim time = {sec_to_time(self.env.now)}.') 47 | 48 | self.connection = create_engine(get_db_url(), pool_size=20, max_overflow=0, pool_pre_ping=True) 49 | self.dispatcher = Dispatcher( 50 | env=self.env, 51 | cancellation_policy=DISPATCHER_CANCELLATION_POLICIES_MAP[settings.DISPATCHER_CANCELLATION_POLICY], 52 | buffering_policy=DISPATCHER_BUFFERING_POLICIES_MAP[settings.DISPATCHER_BUFFERING_POLICY], 53 | matching_policy=DISPATCHER_MATCHING_POLICIES_MAP[settings.DISPATCHER_MATCHING_POLICY], 54 | prepositioning_policy=DISPATCHER_PREPOSITIONING_POLICIES_MAP[settings.DISPATCHER_PREPOSITIONING_POLICY], 55 | prepositioning_evaluation_policy=DISPATCHER_PREPOSITIONING_EVALUATION_POLICIES_MAP[ 56 | settings.DISPATCHER_PREPOSITIONING_EVALUATION_POLICY 57 | ] 58 | ) 59 | self.process = self.env.process(self._simulate()) 60 | 61 | def _simulate(self): 62 | """ 63 | State that simulates the ongoing World of the simulated environment. 64 | Each second the World checks the DDBB to see which couriers log on and which users place orders. 65 | A general log shows the ongoing simulation progress 66 | """ 67 | 68 | while True: 69 | orders_info = self._new_orders_info(current_time=sec_to_time(self.env.now)) 70 | if orders_info is not None: 71 | self._new_users_procedure(orders_info) 72 | 73 | couriers_info = self._new_couriers_info(current_time=sec_to_time(self.env.now)) 74 | if couriers_info is not None: 75 | self._new_couriers_procedure(couriers_info) 76 | 77 | logging.info( 78 | f'Instance {self.instance} | sim time = {sec_to_time(self.env.now)} ' 79 | f'{world_log(self.dispatcher)}' 80 | ) 81 | 82 | yield self.env.timeout(delay=1) 83 | 84 | def _new_orders_info(self, current_time: time) -> Optional[List[Dict[str, Any]]]: 85 | """Method that returns the list of new users that log on at a given time""" 86 | 87 | if settings.CREATE_USERS_FROM <= current_time <= settings.CREATE_USERS_UNTIL: 88 | query = orders_query.format( 89 | placement_time=time_to_query_format(current_time), 90 | instance_id=self.instance 91 | ) 92 | orders_df = pd.read_sql(sql=query, con=self.connection) 93 | else: 94 | orders_df = pd.DataFrame() 95 | 96 | return orders_df.to_dict('records') if not orders_df.empty else None 97 | 98 | def _new_couriers_info(self, current_time: time) -> Optional[List[Dict[str, Any]]]: 99 | """Method that returns the list of new couriers that log on at a given time""" 100 | 101 | if settings.CREATE_COURIERS_FROM <= current_time <= settings.CREATE_COURIERS_UNTIL: 102 | query = couriers_query.format( 103 | on_time=time_to_query_format(current_time), 104 | instance_id=self.instance 105 | ) 106 | couriers_df = pd.read_sql(sql=query, con=self.connection) 107 | else: 108 | couriers_df = pd.DataFrame() 109 | 110 | return couriers_df.to_dict('records') if not couriers_df.empty else None 111 | 112 | def _new_users_procedure(self, orders_info: List[Dict[str, Any]]): 113 | """Method to establish how a new user is created in the World""" 114 | 115 | for order_info in orders_info: 116 | user = User( 117 | env=self.env, 118 | dispatcher=self.dispatcher, 119 | cancellation_policy=USER_CANCELLATION_POLICIES_MAP[settings.USER_CANCELLATION_POLICY], 120 | user_id=order_info['order_id'] 121 | ) 122 | user.submit_order_event( 123 | order_id=order_info['order_id'], 124 | pick_up_at=Location(lat=order_info['pick_up_lat'], lng=order_info['pick_up_lng']), 125 | drop_off_at=Location(lat=order_info['drop_off_lat'], lng=order_info['drop_off_lng']), 126 | placement_time=order_info['placement_time'], 127 | expected_drop_off_time=order_info['expected_drop_off_time'], 128 | preparation_time=order_info['preparation_time'], 129 | ready_time=order_info['ready_time'] 130 | ) 131 | self.users.append(user) 132 | 133 | def _new_couriers_procedure(self, couriers_info: List[Dict[str, Any]]): 134 | """Method to establish how a new courier is created in the World""" 135 | 136 | for courier_info in couriers_info: 137 | courier = Courier( 138 | env=self.env, 139 | dispatcher=self.dispatcher, 140 | acceptance_policy=COURIER_ACCEPTANCE_POLICIES_MAP[settings.COURIER_ACCEPTANCE_POLICY], 141 | movement_evaluation_policy=COURIER_MOVEMENT_EVALUATION_POLICIES_MAP[ 142 | settings.COURIER_MOVEMENT_EVALUATION_POLICY 143 | ], 144 | movement_policy=COURIER_MOVEMENT_POLICIES_MAP[settings.COURIER_MOVEMENT_POLICY], 145 | courier_id=courier_info['courier_id'], 146 | vehicle=Vehicle.from_label(label=courier_info['vehicle']), 147 | location=Location(lat=courier_info['on_lat'], lng=courier_info['on_lng']), 148 | on_time=courier_info['on_time'], 149 | off_time=courier_info['off_time'] 150 | ) 151 | self.couriers.append(courier) 152 | 153 | def post_process(self): 154 | """Post process what happened in the World before calculating metrics for the Courier and the Order""" 155 | 156 | logging.info(f'Instance {self.instance} | Simulation finished at sim time = {sec_to_time(self.env.now)}.') 157 | 158 | for courier_id, courier in self.dispatcher.idle_couriers.copy().items(): 159 | courier.off_time = sec_to_time(self.env.now) 160 | courier.log_off_event() 161 | 162 | warm_up_time_start = time_add(settings.SIMULATE_FROM, settings.WARM_UP_TIME) 163 | 164 | for order_id, order in self.dispatcher.canceled_orders.copy().items(): 165 | if order.cancellation_time < warm_up_time_start: 166 | del self.dispatcher.canceled_orders[order_id] 167 | 168 | for order_id, order in self.dispatcher.fulfilled_orders.copy().items(): 169 | if order.drop_off_time < warm_up_time_start: 170 | del self.dispatcher.fulfilled_orders[order_id] 171 | 172 | logging.info(f'Instance {self.instance} | Post processed the simulation.') 173 | system( 174 | f'say The simulation process for instance {self.instance}, ' 175 | f'matching policy {settings.DISPATCHER_MATCHING_POLICY} has finished.' 176 | ) 177 | -------------------------------------------------------------------------------- /alembic.ini: -------------------------------------------------------------------------------- 1 | # A generic, single database configuration. 2 | 3 | [alembic] 4 | # path to migration scripts 5 | script_location = ddbb 6 | 7 | # template used to generate migration files 8 | # file_template = %%(rev)s_%%(slug)s 9 | 10 | # timezone to use when rendering the date 11 | # within the migration file as well as the filename. 12 | # string value is passed to dateutil.tz.gettz() 13 | # leave blank for localtime 14 | # timezone = 15 | 16 | # max length of characters to apply to the 17 | # "slug" field 18 | #truncate_slug_length = 40 19 | 20 | # set to 'true' to run the environment during 21 | # the 'revision' command, regardless of autogenerate 22 | # revision_environment = false 23 | 24 | # set to 'true' to allow .pyc and .pyo files without 25 | # a source .py file to be detected as revisions in the 26 | # versions/ directory 27 | # sourceless = false 28 | 29 | # version location specification; this defaults 30 | # to db/versions. When using multiple version 31 | # directories, initial revisions must be specified with --version-path 32 | # version_locations = %(here)s/bar %(here)s/bat db/versions 33 | 34 | # the output encoding used when revision files 35 | # are written from script.py.mako 36 | # output_encoding = utf-8 37 | 38 | sqlalchemy.url = driver://user:pass@localhost/dbname 39 | 40 | 41 | # Logging configuration 42 | [loggers] 43 | keys = root,sqlalchemy,alembic 44 | 45 | [handlers] 46 | keys = console 47 | 48 | [formatters] 49 | keys = generic 50 | 51 | [logger_root] 52 | level = WARN 53 | handlers = console 54 | qualname = 55 | 56 | [logger_sqlalchemy] 57 | level = WARN 58 | handlers = 59 | qualname = sqlalchemy.engine 60 | 61 | [logger_alembic] 62 | level = INFO 63 | handlers = 64 | qualname = alembic 65 | 66 | [handler_console] 67 | class = StreamHandler 68 | args = (sys.stderr,) 69 | level = NOTSET 70 | formatter = generic 71 | 72 | [formatter_generic] 73 | format = %(levelname)-5.5s [%(name)s] %(message)s 74 | datefmt = %H:%M:%S 75 | -------------------------------------------------------------------------------- /ddbb/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sebastian-quintero/mdrp-sim/94656681006f7bbc32b2f1970e288944c52d308a/ddbb/__init__.py -------------------------------------------------------------------------------- /ddbb/config.py: -------------------------------------------------------------------------------- 1 | import alembic.config 2 | 3 | import settings 4 | 5 | 6 | def get_db_url(): 7 | return "postgresql://{}:{}@{}:{}/{}".format( 8 | settings.DB_USERNAME, 9 | settings.DB_PASSWORD, 10 | settings.DB_HOST, 11 | settings.DB_PORT, 12 | settings.DB_DATABASE, 13 | ) 14 | 15 | 16 | def run_db_migrations(): 17 | alembic_args = [ 18 | '--raiseerr', 19 | 'upgrade', 'head', 20 | ] 21 | alembic.config.main(argv=alembic_args) 22 | -------------------------------------------------------------------------------- /ddbb/env.py: -------------------------------------------------------------------------------- 1 | from __future__ import with_statement 2 | 3 | from alembic import context 4 | from sqlalchemy import create_engine 5 | 6 | from ddbb.config import get_db_url 7 | # this is the Alembic Config object, which provides 8 | # access to the values within the .ini file in use. 9 | from ddbb.tables.base import Base 10 | 11 | config = context.config 12 | 13 | # Interpret the config file for Python logging. 14 | # This line sets up loggers basically. 15 | # fileConfig(config.config_file_name) 16 | 17 | # add your model's MetaData object here 18 | # for 'autogenerate' support 19 | # from myapp import mymodel 20 | # target_metadata = mymodel.Base.metadata 21 | target_metadata = Base.metadata 22 | 23 | 24 | # other values from the config, defined by the needs of env.py, 25 | # can be acquired: 26 | # my_important_option = config.get_main_option("my_important_option") 27 | # ... etc. 28 | 29 | 30 | def run_migrations_offline(): 31 | """Run migrations in 'offline' mode. 32 | 33 | This configures the context with just a URL 34 | and not an Engine, though an Engine is acceptable 35 | here as well. By skipping the Engine creation 36 | we don't even need a DBAPI to be available. 37 | 38 | Calls to context.execute() here emit the given string to the 39 | script output. 40 | 41 | """ 42 | url = get_db_url() 43 | context.configure( 44 | url=url, target_metadata=target_metadata, literal_binds=True) 45 | 46 | with context.begin_transaction(): 47 | context.run_migrations() 48 | 49 | 50 | def run_migrations_online(): 51 | """Run migrations in 'online' mode. 52 | 53 | In this scenario we need to create an Engine 54 | and associate a connection with the context. 55 | 56 | """ 57 | connectable = create_engine(get_db_url()) 58 | 59 | with connectable.connect() as connection: 60 | context.configure( 61 | connection=connection, 62 | target_metadata=target_metadata 63 | ) 64 | 65 | with context.begin_transaction(): 66 | context.run_migrations() 67 | 68 | 69 | if context.is_offline_mode(): 70 | run_migrations_offline() 71 | else: 72 | run_migrations_online() 73 | -------------------------------------------------------------------------------- /ddbb/load_instances.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os 3 | 4 | import pandas as pd 5 | from sqlalchemy import create_engine, Integer, Float, Time, String 6 | 7 | from ddbb.config import get_db_url, run_db_migrations 8 | from utils.logging_utils import configure_logs 9 | 10 | PROJECT_PATH = os.getcwd() 11 | INSTANCES_DIR_PATH = f'{PROJECT_PATH}/instances' 12 | INSTANCES_SUB_DIR_PATH = INSTANCES_DIR_PATH + '/{instance_id}' 13 | ORDERS_CSV_FILE = 'orders.csv' 14 | COURIERS_CSV_FILE = 'couriers.csv' 15 | 16 | if __name__ == '__main__': 17 | """Method to load .csv instances to local DDBB for fast processing""" 18 | 19 | configure_logs() 20 | run_db_migrations() 21 | 22 | connection = create_engine(get_db_url(), pool_size=20, max_overflow=0, pool_pre_ping=True) 23 | logging.info('Successfully created DDBB connection.') 24 | 25 | instances = os.listdir(INSTANCES_DIR_PATH) 26 | instances = [instance for instance in instances if instance != '.DS_Store'] 27 | logging.info(f'Start loading data to local DDBB for {len(instances)} instances.') 28 | 29 | for instance in instances: 30 | sub_dir = INSTANCES_SUB_DIR_PATH.format(instance_id=instance) 31 | orders_file = f'{sub_dir}/{ORDERS_CSV_FILE}' 32 | couriers_file = f'{sub_dir}/{COURIERS_CSV_FILE}' 33 | logging.info(f'Instance {instance} | Successfully read orders and couriers files.') 34 | 35 | orders_df = pd.read_csv(orders_file) 36 | orders_df['instance_id'] = instance 37 | logging.info( 38 | f'Instance {instance} | Successfully obtained orders DF with these columns: ' 39 | f'{orders_df.columns.tolist()}.' 40 | ) 41 | 42 | couriers_df = pd.read_csv(couriers_file) 43 | couriers_df['instance_id'] = instance 44 | logging.info( 45 | f'Instance {instance} | Successfully obtained couriers DF with these columns: ' 46 | f'{couriers_df.columns.tolist()}.' 47 | ) 48 | 49 | orders_df.to_sql( 50 | name='orders_instance_data', 51 | con=connection, 52 | if_exists='append', 53 | index=False, 54 | dtype={ 55 | 'instance_id': Integer, 56 | 'order_id': Integer, 57 | 'pick_up_lat': Float, 58 | 'pick_up_lng': Float, 59 | 'drop_off_lat': Float, 60 | 'drop_off_lng': Float, 61 | 'placement_time': Time, 62 | 'preparation_time': Time, 63 | 'ready_time': Time, 64 | 'expected_drop_off_time': Time 65 | } 66 | ) 67 | logging.info(f'Instance {instance} | Successfully transferred orders DF to SQL table.') 68 | 69 | couriers_df.to_sql( 70 | name='couriers_instance_data', 71 | con=connection, 72 | if_exists='append', 73 | index=False, 74 | dtype={ 75 | 'instance_id': Integer, 76 | 'courier_id': Integer, 77 | 'vehicle': String, 78 | 'on_lat': Float, 79 | 'on_lng': Float, 80 | 'on_time': Time, 81 | 'off_time': Time 82 | } 83 | ) 84 | logging.info(f'Instance {instance} | Successfully transferred couriers DF to SQL table.') 85 | 86 | connection.dispose() 87 | logging.info('Successfully finished loading instances and disposed of DDBB connection.') 88 | -------------------------------------------------------------------------------- /ddbb/queries/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sebastian-quintero/mdrp-sim/94656681006f7bbc32b2f1970e288944c52d308a/ddbb/queries/__init__.py -------------------------------------------------------------------------------- /ddbb/queries/couriers_instance_data_query.py: -------------------------------------------------------------------------------- 1 | couriers_query = """ 2 | SELECT 3 | courier_id, 4 | vehicle, 5 | on_lat, 6 | on_lng, 7 | on_time, 8 | off_time 9 | FROM couriers_instance_data 10 | WHERE on_time = {on_time} AND instance_id = {instance_id} 11 | """ -------------------------------------------------------------------------------- /ddbb/queries/orders_instance_data_query.py: -------------------------------------------------------------------------------- 1 | orders_query = """ 2 | SELECT 3 | order_id, 4 | pick_up_lat, 5 | pick_up_lng, 6 | drop_off_lat, 7 | drop_off_lng, 8 | placement_time, 9 | preparation_time, 10 | ready_time, 11 | expected_drop_off_time 12 | FROM orders_instance_data 13 | WHERE placement_time = {placement_time} AND instance_id = {instance_id} 14 | """ 15 | -------------------------------------------------------------------------------- /ddbb/script.py.mako: -------------------------------------------------------------------------------- 1 | """${message} 2 | 3 | Revision ID: ${up_revision} 4 | Revises: ${down_revision | comma,n} 5 | Create Date: ${create_date} 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | ${imports if imports else ""} 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = ${repr(up_revision)} 14 | down_revision = ${repr(down_revision)} 15 | branch_labels = ${repr(branch_labels)} 16 | depends_on = ${repr(depends_on)} 17 | 18 | 19 | def upgrade(): 20 | ${upgrades if upgrades else "pass"} 21 | 22 | 23 | def downgrade(): 24 | ${downgrades if downgrades else "pass"} 25 | -------------------------------------------------------------------------------- /ddbb/tables/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sebastian-quintero/mdrp-sim/94656681006f7bbc32b2f1970e288944c52d308a/ddbb/tables/__init__.py -------------------------------------------------------------------------------- /ddbb/tables/base.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | 3 | from sqlalchemy.ext.declarative import declarative_base 4 | 5 | Base = declarative_base() 6 | 7 | 8 | class TableModel: 9 | parameters = [] 10 | 11 | def to_dict(self): 12 | return {param: self.get_parameter(param) for param in self.parameters} 13 | 14 | def get_parameter(self, parameter): 15 | if parameter == 'created_at' or parameter == 'updated_at': 16 | return str(datetime.datetime.now()) 17 | return getattr(self, parameter) 18 | -------------------------------------------------------------------------------- /ddbb/versions/65698a586556_create_instance_data_tables.py: -------------------------------------------------------------------------------- 1 | """create instance data tables 2 | 3 | Revision ID: 65698a586556 4 | Revises: 5 | Create Date: 2020-09-25 01:54:58.352259 6 | 7 | """ 8 | import sqlalchemy as sa 9 | from alembic import op 10 | 11 | # revision identifiers, used by Alembic. 12 | revision = '65698a586556' 13 | down_revision = None 14 | branch_labels = None 15 | depends_on = None 16 | 17 | 18 | def upgrade(): 19 | op.create_table( 20 | 'orders_instance_data', 21 | sa.Column('instance_id', sa.Integer(), nullable=False), 22 | sa.Column('order_id', sa.Integer(), nullable=False), 23 | sa.Column('pick_up_lat', sa.Float(), nullable=False), 24 | sa.Column('pick_up_lng', sa.Float(), nullable=False), 25 | sa.Column('drop_off_lat', sa.Float(), nullable=False), 26 | sa.Column('drop_off_lng', sa.Float(), nullable=False), 27 | sa.Column('placement_time', sa.Time(), nullable=False), 28 | sa.Column('preparation_time', sa.Time(), nullable=False), 29 | sa.Column('ready_time', sa.Time(), nullable=False), 30 | sa.Column('expected_drop_off_time', sa.Time(), nullable=False), 31 | ) 32 | op.create_index('ix_orders_instance_data_instance_id', 'orders_instance_data', ['instance_id'], unique=False) 33 | op.create_index('ix_orders_instance_data_placement_time', 'orders_instance_data', ['placement_time'], unique=False) 34 | 35 | op.create_table( 36 | 'couriers_instance_data', 37 | sa.Column('instance_id', sa.Integer(), nullable=False), 38 | sa.Column('courier_id', sa.Integer(), nullable=False), 39 | sa.Column('vehicle', sa.String(), nullable=False), 40 | sa.Column('on_lat', sa.Float(), nullable=False), 41 | sa.Column('on_lng', sa.Float(), nullable=False), 42 | sa.Column('on_time', sa.Time(), nullable=False), 43 | sa.Column('off_time', sa.Time(), nullable=False), 44 | ) 45 | op.create_index('ix_couriers_instance_data_instance_id', 'couriers_instance_data', ['instance_id'], unique=False) 46 | op.create_index('ix_couriers_instance_data_on_time', 'couriers_instance_data', ['on_time'], unique=False) 47 | 48 | 49 | def downgrade(): 50 | op.drop_table('orders_instance_data') 51 | op.drop_index('ix_orders_instance_data_instance_id', table_name='orders_instance_data') 52 | op.drop_index('ix_orders_instance_data_placement_time', table_name='orders_instance_data') 53 | 54 | op.drop_table('couriers_instance_data') 55 | op.drop_index('ix_couriers_instance_data_instance_id', table_name='couriers_instance_data') 56 | op.drop_index('ix_couriers_instance_data_on_time', table_name='couriers_instance_data') 57 | -------------------------------------------------------------------------------- /ddbb/versions/b2388948ee69_create_metrics_tables.py: -------------------------------------------------------------------------------- 1 | """create metrics tables 2 | 3 | Revision ID: b2388948ee69 4 | Revises: 65698a586556 5 | Create Date: 2020-10-02 21:55:35.986237 6 | 7 | """ 8 | import sqlalchemy as sa 9 | from alembic import op 10 | # revision identifiers, used by Alembic. 11 | from sqlalchemy import func 12 | 13 | revision = 'b2388948ee69' 14 | down_revision = '65698a586556' 15 | branch_labels = None 16 | depends_on = None 17 | 18 | 19 | def upgrade(): 20 | op.create_table( 21 | 'order_metrics', 22 | sa.Column('created_at', sa.DateTime(), server_default=func.now(), nullable=True), 23 | sa.Column('instance_id', sa.Integer(), nullable=False), 24 | sa.Column('simulation_settings', sa.JSON(), nullable=True), 25 | sa.Column('simulation_policies', sa.JSON(), nullable=True), 26 | sa.Column('extra_settings', sa.JSON(), nullable=True), 27 | sa.Column('order_id', sa.Integer(), nullable=True), 28 | sa.Column('placement_time', sa.Time(), nullable=True), 29 | sa.Column('preparation_time', sa.Time(), nullable=True), 30 | sa.Column('acceptance_time', sa.Time(), nullable=True), 31 | sa.Column('in_store_time', sa.Time(), nullable=True), 32 | sa.Column('ready_time', sa.Time(), nullable=True), 33 | sa.Column('pick_up_time', sa.Time(), nullable=True), 34 | sa.Column('drop_off_time', sa.Time(), nullable=True), 35 | sa.Column('expected_drop_off_time', sa.Time(), nullable=True), 36 | sa.Column('cancellation_time', sa.Time(), nullable=True), 37 | sa.Column('dropped_off', sa.Boolean(), nullable=True), 38 | sa.Column('click_to_door_time', sa.Float(), nullable=True), 39 | sa.Column('click_to_taken_time', sa.Float(), nullable=True), 40 | sa.Column('ready_to_door_time', sa.Float(), nullable=True), 41 | sa.Column('ready_to_pick_up_time', sa.Float(), nullable=True), 42 | sa.Column('in_store_to_pick_up_time', sa.Float(), nullable=True), 43 | sa.Column('drop_off_lateness_time', sa.Float(), nullable=True), 44 | sa.Column('click_to_cancel_time', sa.Float(), nullable=True), 45 | ) 46 | op.create_index('ix_order_metrics_created_at', 'order_metrics', ['created_at'], unique=False) 47 | op.create_index('ix_order_metrics_instance_id', 'order_metrics', ['instance_id'], unique=False) 48 | 49 | op.create_table( 50 | 'courier_metrics', 51 | sa.Column('created_at', sa.DateTime(), server_default=func.now(), nullable=True), 52 | sa.Column('instance_id', sa.Integer(), nullable=False), 53 | sa.Column('simulation_settings', sa.JSON(), nullable=True), 54 | sa.Column('simulation_policies', sa.JSON(), nullable=True), 55 | sa.Column('extra_settings', sa.JSON(), nullable=True), 56 | sa.Column('courier_id', sa.Integer(), nullable=True), 57 | sa.Column('on_time', sa.Time(), nullable=True), 58 | sa.Column('off_time', sa.Time(), nullable=True), 59 | sa.Column('fulfilled_orders', sa.Integer(), nullable=True), 60 | sa.Column('earnings', sa.Float(), nullable=True), 61 | sa.Column('utilization_time', sa.Float(), nullable=True), 62 | sa.Column('accepted_notifications', sa.Integer(), nullable=True), 63 | sa.Column('guaranteed_compensation', sa.Boolean(), nullable=True), 64 | sa.Column('courier_utilization', sa.Float(), nullable=True), 65 | sa.Column('courier_delivery_earnings', sa.Float(), nullable=True), 66 | sa.Column('courier_compensation', sa.Float(), nullable=True), 67 | sa.Column('courier_orders_delivered_per_hour', sa.Float(), nullable=True), 68 | sa.Column('courier_bundles_picked_per_hour', sa.Float(), nullable=True), 69 | ) 70 | op.create_index('ix_courier_metrics_created_at', 'courier_metrics', ['created_at'], unique=False) 71 | op.create_index('ix_courier_metrics_instance_id', 'courier_metrics', ['instance_id'], unique=False) 72 | 73 | op.create_table( 74 | 'matching_optimization_metrics', 75 | sa.Column('created_at', sa.DateTime(), server_default=func.now(), nullable=True), 76 | sa.Column('instance_id', sa.Integer(), nullable=False), 77 | sa.Column('simulation_settings', sa.JSON(), nullable=True), 78 | sa.Column('simulation_policies', sa.JSON(), nullable=True), 79 | sa.Column('extra_settings', sa.JSON(), nullable=True), 80 | sa.Column('id', sa.Integer(), nullable=True), 81 | sa.Column('orders', sa.Integer(), nullable=True), 82 | sa.Column('routes', sa.Integer(), nullable=True), 83 | sa.Column('couriers', sa.Integer(), nullable=True), 84 | sa.Column('variables', sa.BigInteger(), nullable=True), 85 | sa.Column('constraints', sa.BigInteger(), nullable=True), 86 | sa.Column('routing_time', sa.Float(), nullable=True), 87 | sa.Column('matching_time', sa.Float(), nullable=True), 88 | sa.Column('matches', sa.Integer(), nullable=True) 89 | ) 90 | op.create_index( 91 | 'ix_matching_optimization_metrics_created_at', 92 | 'matching_optimization_metrics', 93 | ['created_at'], 94 | unique=False 95 | ) 96 | op.create_index( 97 | 'ix_matching_optimization_metrics_instance_id', 98 | 'matching_optimization_metrics', 99 | ['instance_id'], 100 | unique=False 101 | ) 102 | 103 | 104 | def downgrade(): 105 | op.drop_table('order_metrics') 106 | op.drop_index('ix_order_metrics_created_at', table_name='order_metrics') 107 | op.drop_index('ix_order_metrics_instance_id', table_name='order_metrics') 108 | 109 | op.drop_table('courier_metrics') 110 | op.drop_index('ix_courier_metrics_created_at', table_name='courier_metrics') 111 | op.drop_index('ix_courier_metrics_instance_id', table_name='courier_metrics') 112 | 113 | op.drop_table('matching_optimization_metrics') 114 | op.drop_index('ix_matching_optimization_metrics_created_at', table_name='matching_optimization_metrics') 115 | op.drop_index('ix_matching_optimization_metrics_instance_id', table_name='matching_optimization_metrics') 116 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | --- 2 | version: '3.7' 3 | 4 | services: 5 | osrm_colombia: 6 | image: colombia_osrm 7 | build: 8 | context: . 9 | dockerfile: ./docker/colombia.dockerfile 10 | ports: 11 | - "5000:5000" -------------------------------------------------------------------------------- /docker/colombia.dockerfile: -------------------------------------------------------------------------------- 1 | FROM osrm/osrm-backend:v5.22.0 2 | 3 | ENV continent=south-america 4 | ENV country=colombia 5 | ENV city=colombia 6 | 7 | ARG bounding_box=-76.769106,3.139265,-72.394630,11.410498 8 | ARG source=http://download.geofabrik.de/${continent}/${country}-latest.osm.pbf 9 | 10 | RUN apt --yes update 11 | RUN apt --yes install wget 12 | RUN apt install --yes osmium-tool 13 | RUN wget ${source} -O source.pbf 14 | RUN osmium extract --bbox ${bounding_box} source.pbf -o ${city}.pbf 15 | RUN /usr/local/bin/osrm-extract -p /opt/car.lua ${city}.pbf 16 | RUN /usr/local/bin/osrm-contract ${city}.osrm 17 | 18 | ENTRYPOINT osrm-routed --algorithm ch ${city}.osrm -------------------------------------------------------------------------------- /objects/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sebastian-quintero/mdrp-sim/94656681006f7bbc32b2f1970e288944c52d308a/objects/__init__.py -------------------------------------------------------------------------------- /objects/location.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from typing import Tuple 3 | 4 | 5 | @dataclass 6 | class Location: 7 | lat: float 8 | lng: float 9 | 10 | @property 11 | def coordinates(self) -> Tuple[float, float]: 12 | return self.lat, self.lng 13 | -------------------------------------------------------------------------------- /objects/matching_metric.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from typing import Any, Dict 3 | 4 | from numpy import int64 5 | 6 | 7 | @dataclass 8 | class MatchingMetric: 9 | """Class to store results of a dispatch event""" 10 | 11 | constraints: int64 12 | couriers: int 13 | matches: int 14 | matching_time: float 15 | orders: int 16 | routes: int 17 | routing_time: float 18 | variables: int64 19 | 20 | def calculate_metrics(self) -> Dict[str, Any]: 21 | """Method to calculate metrics of a dispatch event""" 22 | 23 | return self.__dict__ 24 | -------------------------------------------------------------------------------- /objects/notification.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from enum import IntEnum 3 | from typing import Union, Optional, Any, List 4 | 5 | from objects.route import Route 6 | from objects.stop import Stop 7 | 8 | LABELS = { 9 | 0: 'pick_and_drop', 10 | 1: 'prepositioning' 11 | } 12 | 13 | 14 | class NotificationType(IntEnum): 15 | """Class that defines the possible values of a notification type""" 16 | 17 | PICK_UP_DROP_OFF = 0 18 | PREPOSITIONING = 1 19 | 20 | @property 21 | def label(self): 22 | """Property that returns the notification type's label""" 23 | 24 | return LABELS[self] 25 | 26 | 27 | @dataclass 28 | class Notification: 29 | """Class that represents a notification of a new Route or Stop""" 30 | 31 | courier: Optional[Any] 32 | instruction: Optional[Union[Route, List[Stop]]] 33 | type: NotificationType = NotificationType.PICK_UP_DROP_OFF 34 | 35 | def update(self, processed_order_ids: List[int]): 36 | """Method to update a notification if some of its orders have been processed""" 37 | 38 | if isinstance(self.instruction, Route): 39 | self.instruction.update(processed_order_ids) 40 | 41 | else: 42 | updated_stops, num_stops = [], 0 43 | for stop in self.instruction: 44 | updated_orders = { 45 | order_id: order 46 | for order_id, order in stop.orders.items() 47 | if order_id not in processed_order_ids 48 | } 49 | 50 | if bool(updated_orders): 51 | updated_stops.append( 52 | Stop( 53 | arrive_at=stop.arrive_at, 54 | location=stop.location, 55 | orders=updated_orders, 56 | position=num_stops, 57 | type=stop.type, 58 | visited=stop.visited 59 | ) 60 | ) 61 | num_stops += 1 62 | 63 | self.instruction = updated_stops 64 | -------------------------------------------------------------------------------- /objects/order.py: -------------------------------------------------------------------------------- 1 | import random 2 | from dataclasses import dataclass, field 3 | from datetime import time 4 | from typing import Optional, List, Any, Dict 5 | 6 | from geohash import encode 7 | 8 | from objects.location import Location 9 | from settings import settings 10 | from utils.datetime_utils import time_diff 11 | 12 | 13 | @dataclass 14 | class Order: 15 | """A class used to handle an order's state and events""" 16 | 17 | order_id: Optional[int] = None 18 | courier_id: Optional[int] = None 19 | drop_off_at: Optional[Location] = None 20 | pick_up_at: Optional[Location] = None 21 | rejected_by: Optional[List[int]] = field(default_factory=lambda: list()) 22 | state: Optional[str] = '' 23 | user: Optional[Any] = None 24 | 25 | acceptance_time: Optional[time] = None 26 | cancellation_time: Optional[time] = None 27 | drop_off_service_time: Optional[float] = None 28 | drop_off_time: Optional[time] = None 29 | expected_drop_off_time: Optional[time] = None 30 | geohash: Optional[str] = None 31 | in_store_time: Optional[time] = None 32 | pick_up_time: Optional[time] = None 33 | pick_up_service_time: Optional[float] = None 34 | placement_time: Optional[time] = None 35 | preparation_time: Optional[time] = None 36 | ready_time: Optional[time] = None 37 | 38 | def __post_init__(self): 39 | """Randomly assigns missing properties immediately after the order is created and other initializations""" 40 | 41 | self.pick_up_service_time = random.randint( 42 | settings.ORDER_MIN_SERVICE_TIME, 43 | settings.ORDER_MAX_PICK_UP_SERVICE_TIME 44 | ) if self.pick_up_service_time is None else self.pick_up_service_time 45 | self.drop_off_service_time = random.randint( 46 | settings.ORDER_MIN_SERVICE_TIME, 47 | settings.ORDER_MAX_DROP_OFF_SERVICE_TIME 48 | ) if self.drop_off_service_time is None else self.drop_off_service_time 49 | self.state = 'unassigned' 50 | self.geohash = ( 51 | encode(self.pick_up_at.lat, self.pick_up_at.lng, settings.DISPATCHER_GEOHASH_PRECISION_GROUPING) 52 | if self.pick_up_at is not None 53 | else '' 54 | ) 55 | 56 | def calculate_metrics(self) -> Dict[str, Any]: 57 | """Method to calculate the metrics of an order""" 58 | 59 | dropped_off = bool(self.drop_off_time) 60 | 61 | if dropped_off: 62 | click_to_door_time = time_diff(self.drop_off_time, self.placement_time) 63 | click_to_taken_time = time_diff(self.acceptance_time, self.placement_time) 64 | ready_to_door_time = time_diff(self.drop_off_time, self.ready_time) 65 | ready_to_pickup_time = time_diff(self.pick_up_time, self.ready_time) 66 | in_store_to_pickup_time = time_diff(self.pick_up_time, self.in_store_time) 67 | drop_off_lateness_time = time_diff(self.drop_off_time, self.expected_drop_off_time) 68 | click_to_cancel_time = None 69 | 70 | else: 71 | click_to_door_time = None 72 | click_to_taken_time = None 73 | ready_to_door_time = None 74 | ready_to_pickup_time = None 75 | in_store_to_pickup_time = None 76 | drop_off_lateness_time = None 77 | click_to_cancel_time = time_diff(self.cancellation_time, self.preparation_time) 78 | 79 | return { 80 | 'order_id': self.order_id, 81 | 'placement_time': self.placement_time, 82 | 'preparation_time': self.preparation_time, 83 | 'acceptance_time': self.acceptance_time, 84 | 'in_store_time': self.in_store_time, 85 | 'ready_time': self.ready_time, 86 | 'pick_up_time': self.pick_up_time, 87 | 'drop_off_time': self.drop_off_time, 88 | 'expected_drop_off_time': self.expected_drop_off_time, 89 | 'cancellation_time': self.cancellation_time, 90 | 'dropped_off': dropped_off, 91 | 'click_to_door_time': click_to_door_time, 92 | 'click_to_taken_time': click_to_taken_time, 93 | 'ready_to_door_time': ready_to_door_time, 94 | 'ready_to_pick_up_time': ready_to_pickup_time, 95 | 'in_store_to_pick_up_time': in_store_to_pickup_time, 96 | 'drop_off_lateness_time': drop_off_lateness_time, 97 | 'click_to_cancel_time': click_to_cancel_time 98 | } 99 | -------------------------------------------------------------------------------- /objects/route.py: -------------------------------------------------------------------------------- 1 | import copy 2 | import uuid 3 | from dataclasses import dataclass, field 4 | from typing import List, Optional, Dict, Any 5 | 6 | from objects.location import Location 7 | from objects.order import Order 8 | from objects.stop import Stop, StopType 9 | from objects.vehicle import Vehicle 10 | from utils.datetime_utils import time_to_sec 11 | 12 | 13 | @dataclass 14 | class Route: 15 | """Class describing a route for either moving or fulfilling""" 16 | 17 | initial_prospect: int = None 18 | num_stops: Optional[int] = 0 19 | orders: Optional[Dict[int, Order]] = field(default_factory=lambda: dict()) 20 | route_id: Optional[str] = '' 21 | stops: Optional[List[Stop]] = field(default_factory=lambda: list()) 22 | time: Optional[Dict[Any, float]] = field(default_factory=lambda: dict()) 23 | 24 | def __post_init__(self): 25 | """Post process of the route creation""" 26 | self.stops = [Stop()] * self.num_stops if self.num_stops else self.stops 27 | self.num_stops = len(self.stops) 28 | self.time = {v: 0 for v in Vehicle} 29 | 30 | if bool(self.orders): 31 | self.time = self._calculate_time() 32 | 33 | self.route_id = str(uuid.uuid4())[0:8] 34 | 35 | def time_since_ready(self, env_time: int) -> float: 36 | """Property to calculate how much time has passed since the route is ready to be picked up""" 37 | 38 | return max(max(env_time - time_to_sec(order.ready_time), 0) for order in self.orders.values()) 39 | 40 | @classmethod 41 | def from_order(cls, order: Order): 42 | """Method to instantiate a route from an order""" 43 | 44 | orders = {order.order_id: order} 45 | pick_up_stop = Stop( 46 | location=order.pick_up_at, 47 | orders=orders, 48 | position=0, 49 | type=StopType.PICK_UP, 50 | visited=False 51 | ) 52 | drop_off_stop = Stop( 53 | location=order.drop_off_at, 54 | orders=orders, 55 | position=1, 56 | type=StopType.DROP_OFF, 57 | visited=False 58 | ) 59 | 60 | return cls( 61 | orders=orders, 62 | stops=[pick_up_stop, drop_off_stop] 63 | ) 64 | 65 | def update(self, processed_order_ids: List[int]): 66 | """Method to update a route if some of its orders have been processed""" 67 | 68 | updated_stops, num_stops = [], 0 69 | for stop in self.stops: 70 | updated_orders = { 71 | order_id: order 72 | for order_id, order in stop.orders.items() 73 | if order_id not in processed_order_ids 74 | } 75 | 76 | if bool(updated_orders): 77 | updated_stops.append( 78 | Stop( 79 | arrive_at=stop.arrive_at, 80 | location=stop.location, 81 | orders=updated_orders, 82 | position=num_stops, 83 | type=stop.type, 84 | visited=stop.visited 85 | ) 86 | ) 87 | num_stops += 1 88 | 89 | self.stops = updated_stops 90 | self.orders = { 91 | order_id: order 92 | for order_id, order in self.orders.items() 93 | if order_id not in processed_order_ids 94 | } 95 | self.num_stops = len(self.stops) 96 | self.time = self._calculate_time() 97 | 98 | def add_order(self, order: Order, route_position: Optional[int] = 1): 99 | """Method to add an order to the route""" 100 | 101 | if not bool(self.orders): 102 | pick_up_stop = Stop( 103 | location=order.pick_up_at, 104 | orders={order.order_id: order}, 105 | position=0, 106 | type=StopType.PICK_UP, 107 | visited=False 108 | ) 109 | drop_off_stop = Stop( 110 | location=order.drop_off_at, 111 | orders={order.order_id: order}, 112 | position=1, 113 | type=StopType.DROP_OFF, 114 | visited=False 115 | ) 116 | self.orders[order.order_id] = order 117 | self.stops[0] = pick_up_stop 118 | self.stops[1] = drop_off_stop 119 | time = self._calculate_time() 120 | 121 | else: 122 | self.orders[order.order_id] = order 123 | self.stops[0].orders[order.order_id] = order 124 | stop = Stop( 125 | location=order.drop_off_at, 126 | orders={order.order_id: order}, 127 | position=route_position, 128 | type=StopType.DROP_OFF, 129 | visited=False 130 | ) 131 | 132 | position = max(route_position, len(self.stops) - 1) 133 | 134 | if position <= len(self.stops) - 1 and not bool(self.stops[position].orders): 135 | self.stops[position] = stop 136 | 137 | else: 138 | position = len(self.stops) 139 | stop.position = position 140 | self.stops.append(stop) 141 | 142 | time = self.calculate_time_update( 143 | destination=stop.location, 144 | origin=self.stops[position - 1].location, 145 | service_time=stop.calculate_service_time() 146 | ) 147 | stop.arrive_at = copy.deepcopy(time) 148 | 149 | self.time = time 150 | self.num_stops = len(self.stops) 151 | 152 | def add_stops(self, target_size: int): 153 | """Method to add empty stops to the route based on a target size""" 154 | 155 | while len(self.stops) - 1 < target_size: 156 | self.stops.append(Stop()) 157 | 158 | self.num_stops = len(self.stops) 159 | 160 | def update_stops(self): 161 | """Method to remove empty stops from the route""" 162 | 163 | stops, counter = [], 0 164 | for stop in self.stops: 165 | if bool(stop.orders): 166 | stops.append( 167 | Stop( 168 | arrive_at=stop.arrive_at, 169 | location=stop.location, 170 | orders=stop.orders, 171 | position=counter, 172 | type=stop.type, 173 | visited=stop.visited 174 | ) 175 | ) 176 | counter += 1 177 | 178 | self.stops = stops 179 | self.num_stops = len(self.stops) 180 | 181 | def calculate_time_update(self, destination: Location, origin: Location, service_time: float) -> Dict[Any, float]: 182 | """Method to update the route time based on a new stop""" 183 | 184 | from services.osrm_service import OSRMService 185 | 186 | time = {v: t for v, t in self.time.items()} 187 | OSRMService.update_estimate_time_for_vehicles( 188 | origin=origin, 189 | destination=destination, 190 | time=time, 191 | service_time=service_time 192 | ) 193 | 194 | return time 195 | 196 | def _calculate_time(self) -> Dict[Any, float]: 197 | """Method to calculate the route time based on the available stops""" 198 | 199 | from services.osrm_service import OSRMService 200 | 201 | stops = [stop for stop in self.stops if bool(stop.orders)] 202 | time = {v: t for v, t in self.time.items()} 203 | 204 | for ix in range(len(stops) - 1): 205 | origin = stops[ix] 206 | destination = stops[ix + 1] 207 | OSRMService.update_estimate_time_for_vehicles( 208 | origin=origin.location, 209 | destination=destination.location, 210 | time=time, 211 | service_time=destination.calculate_service_time() 212 | ) 213 | destination.arrive_at = copy.deepcopy(time) 214 | 215 | return time 216 | -------------------------------------------------------------------------------- /objects/stop.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass, field 2 | from datetime import time 3 | from enum import IntEnum 4 | from typing import Optional, Dict, Any 5 | 6 | from objects.location import Location 7 | from objects.order import Order 8 | from objects.vehicle import Vehicle 9 | 10 | LABELS = { 11 | 0: 'pick_up', 12 | 1: 'drop_off' 13 | } 14 | 15 | 16 | class StopType(IntEnum): 17 | """Class that defines the possible values of a stop type""" 18 | 19 | PICK_UP = 0 20 | DROP_OFF = 1 21 | PREPOSITION = 2 22 | 23 | @property 24 | def label(self): 25 | """Property that returns the stop type's label""" 26 | 27 | return LABELS[self] 28 | 29 | 30 | @dataclass 31 | class Stop: 32 | """Class describing the stop of a route""" 33 | 34 | arrive_at: Optional[Dict[Any, float]] = field(default_factory=lambda: dict()) 35 | location: Optional[Location] = None 36 | orders: Optional[Dict[int, Order]] = None 37 | position: Optional[int] = 0 38 | type: Optional[StopType] = StopType.PICK_UP 39 | visited: Optional[bool] = False 40 | 41 | def __post_init__(self): 42 | """Immediate instantiation of some properties""" 43 | 44 | self.arrive_at = {v: 0 for v in Vehicle} if not bool(self.arrive_at) else self.arrive_at 45 | 46 | def calculate_service_time(self) -> float: 47 | """Method to calculate the service time at a stop""" 48 | 49 | return max(order.drop_off_service_time for order in self.orders.values()) 50 | 51 | def calculate_latest_expected_time(self) -> time: 52 | """Method to calculate the latest expected time for a stop based on its type""" 53 | 54 | if self.type == StopType.PICK_UP: 55 | return max(order.ready_time for order in self.orders.values()) 56 | 57 | elif self.type == StopType.DROP_OFF: 58 | return max(order.expected_drop_off_time for order in self.orders.values()) 59 | -------------------------------------------------------------------------------- /objects/vehicle.py: -------------------------------------------------------------------------------- 1 | from enum import IntEnum 2 | 3 | from utils.datetime_utils import hour_to_sec 4 | 5 | DEFAULT_VELOCITY = { 6 | 0: 5 / hour_to_sec(1), 7 | 1: 15 / hour_to_sec(1), 8 | 2: 23 / hour_to_sec(1), 9 | 3: 25 / hour_to_sec(1) 10 | 11 | } 12 | 13 | LABELS = { 14 | 0: 'walking', 15 | 1: 'bicycle', 16 | 2: 'motorcycle', 17 | 3: 'car' 18 | } 19 | 20 | LABELS_MAP = { 21 | 'walking': 0, 22 | 'bicycle': 1, 23 | 'motorcycle': 2, 24 | 'car': 3 25 | } 26 | 27 | 28 | class Vehicle(IntEnum): 29 | """A class that handles courier vehicles""" 30 | 31 | WALKER = 0 32 | BICYCLE = 1 33 | MOTORCYCLE = 2 34 | CAR = 3 35 | 36 | @property 37 | def average_velocity(self) -> float: 38 | """Property indicating the vehicle's average velocity""" 39 | 40 | return DEFAULT_VELOCITY[self] 41 | 42 | @property 43 | def label(self): 44 | """Property that returns the vehicle's label""" 45 | 46 | return LABELS[self] 47 | 48 | @classmethod 49 | def from_label(cls, label: str): 50 | """Method to create a vehicle from a label""" 51 | 52 | return cls(LABELS_MAP[label]) 53 | -------------------------------------------------------------------------------- /policies/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sebastian-quintero/mdrp-sim/94656681006f7bbc32b2f1970e288944c52d308a/policies/__init__.py -------------------------------------------------------------------------------- /policies/courier/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sebastian-quintero/mdrp-sim/94656681006f7bbc32b2f1970e288944c52d308a/policies/courier/__init__.py -------------------------------------------------------------------------------- /policies/courier/acceptance/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sebastian-quintero/mdrp-sim/94656681006f7bbc32b2f1970e288944c52d308a/policies/courier/acceptance/__init__.py -------------------------------------------------------------------------------- /policies/courier/acceptance/absolute.py: -------------------------------------------------------------------------------- 1 | from typing import Generator, Any 2 | 3 | from simpy import Environment 4 | 5 | from settings import settings 6 | from policies.courier.acceptance.courier_acceptance_policy import CourierAcceptancePolicy 7 | 8 | 9 | class AbsoluteAcceptancePolicy(CourierAcceptancePolicy): 10 | """ 11 | Class containing the policy that decides how a courier evaluates accepting or rejecting a notification. 12 | The courier accepts every notification 13 | """ 14 | 15 | def execute(self, acceptance_rate: float, env: Environment) -> Generator[Any, Any, bool]: 16 | """Execution of the Acceptance Policy""" 17 | 18 | yield env.timeout(delay=settings.COURIER_WAIT_TO_ACCEPT) 19 | 20 | return True 21 | -------------------------------------------------------------------------------- /policies/courier/acceptance/courier_acceptance_policy.py: -------------------------------------------------------------------------------- 1 | from typing import Generator, Any 2 | 3 | from simpy import Environment 4 | 5 | from policies.policy import Policy 6 | 7 | 8 | class CourierAcceptancePolicy(Policy): 9 | """Class that establishes how a courier accepts a notification""" 10 | 11 | ACCEPTANCE_CHOICES = [True, False] 12 | 13 | def execute(self, acceptance_rate: float, env: Environment) -> Generator[Any, Any, bool]: 14 | """Implementation of the policy""" 15 | 16 | pass 17 | -------------------------------------------------------------------------------- /policies/courier/acceptance/random_uniform.py: -------------------------------------------------------------------------------- 1 | import random 2 | from typing import Generator, Any 3 | 4 | from simpy import Environment 5 | 6 | from settings import settings 7 | from policies.courier.acceptance.courier_acceptance_policy import CourierAcceptancePolicy 8 | 9 | 10 | class UniformAcceptancePolicy(CourierAcceptancePolicy): 11 | """ 12 | Class containing the policy that decides how a courier evaluates accepting or rejecting a notification. 13 | It uses a Uniform Distribution to obtain the acceptance rate and a weighted probability to decide. 14 | """ 15 | 16 | def execute(self, acceptance_rate: float, env: Environment) -> Generator[Any, Any, bool]: 17 | """Execution of the Acceptance Policy""" 18 | 19 | yield env.timeout(delay=settings.COURIER_WAIT_TO_ACCEPT) 20 | 21 | return random.choices(self.ACCEPTANCE_CHOICES, weights=(acceptance_rate, 1 - acceptance_rate))[0] 22 | -------------------------------------------------------------------------------- /policies/courier/movement/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sebastian-quintero/mdrp-sim/94656681006f7bbc32b2f1970e288944c52d308a/policies/courier/movement/__init__.py -------------------------------------------------------------------------------- /policies/courier/movement/courier_movement_policy.py: -------------------------------------------------------------------------------- 1 | from simpy import Environment 2 | 3 | from objects.location import Location 4 | from policies.policy import Policy 5 | 6 | 7 | class CourierMovementPolicy(Policy): 8 | """Class that establishes how a courier moves from an origin to a destination""" 9 | 10 | def execute(self, origin: Location, destination: Location, env: Environment, courier): 11 | """Implementation of the policy""" 12 | 13 | pass 14 | -------------------------------------------------------------------------------- /policies/courier/movement/osrm.py: -------------------------------------------------------------------------------- 1 | from haversine import haversine 2 | from simpy import Environment 3 | 4 | from objects.location import Location 5 | from policies.courier.movement.courier_movement_policy import CourierMovementPolicy 6 | from services.osrm_service import OSRMService 7 | 8 | 9 | class OSRMMovementPolicy(CourierMovementPolicy): 10 | """ 11 | Class containing the policy that implements the movement of a courier to a destination. 12 | It uses the Open Source Routing Machine with Open Street Maps. 13 | """ 14 | 15 | def execute(self, origin: Location, destination: Location, env: Environment, courier): 16 | """Execution of the Movement Policy""" 17 | 18 | route = OSRMService.get_route(origin, destination) 19 | 20 | for ix in range(len(route.stops) - 1): 21 | stop = route.stops[ix] 22 | next_stop = route.stops[ix + 1] 23 | 24 | distance = haversine(stop.location.coordinates, next_stop.location.coordinates) 25 | time = int(distance / courier.vehicle.average_velocity) 26 | 27 | yield env.timeout(delay=time) 28 | 29 | courier.location = next_stop.location 30 | -------------------------------------------------------------------------------- /policies/courier/movement_evaluation/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sebastian-quintero/mdrp-sim/94656681006f7bbc32b2f1970e288944c52d308a/policies/courier/movement_evaluation/__init__.py -------------------------------------------------------------------------------- /policies/courier/movement_evaluation/courier_movement_evaluation_policy.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | from objects.location import Location 4 | from policies.policy import Policy 5 | 6 | 7 | class CourierMovementEvaluationPolicy(Policy): 8 | """Class that establishes how a courier decides to change his / her location and the corresponding destination""" 9 | 10 | def execute(self, current_location: Location) -> Optional[Location]: 11 | """Implementation of the policy""" 12 | 13 | pass 14 | -------------------------------------------------------------------------------- /policies/courier/movement_evaluation/geohash_neighbors.py: -------------------------------------------------------------------------------- 1 | import random 2 | from typing import Optional 3 | 4 | import geohash 5 | 6 | from settings import settings 7 | from objects.location import Location 8 | from policies.courier.movement_evaluation.courier_movement_evaluation_policy import CourierMovementEvaluationPolicy 9 | 10 | 11 | class NeighborsMoveEvalPolicy(CourierMovementEvaluationPolicy): 12 | """ 13 | Class containing the policy that decides how a courier evaluates moving about the city. 14 | It decides the destination randomly from the geohash neighbors, using a precision of 6. 15 | The destination is the center of the chosen geohash. 16 | """ 17 | 18 | def execute(self, current_location: Location) -> Optional[Location]: 19 | """Execution of the Movement Evaluation Policy""" 20 | 21 | if random.random() <= settings.COURIER_MOVEMENT_PROBABILITY: 22 | current_geohash = geohash.encode(*current_location.coordinates, precision=6) 23 | geohash_neighbors = geohash.neighbors(current_geohash) 24 | destination_geohash = random.choice(geohash_neighbors) 25 | destination_coordinates = geohash.decode(destination_geohash) 26 | 27 | return Location(lat=destination_coordinates[0], lng=destination_coordinates[1]) 28 | 29 | return None 30 | -------------------------------------------------------------------------------- /policies/courier/movement_evaluation/still.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | from objects.location import Location 4 | from policies.courier.movement_evaluation.courier_movement_evaluation_policy import CourierMovementEvaluationPolicy 5 | 6 | 7 | class StillMoveEvalPolicy(CourierMovementEvaluationPolicy): 8 | """ 9 | Class containing the policy that decides how a courier evaluates moving about the city. 10 | The courier never moves, remaining still. 11 | """ 12 | 13 | def execute(self, current_location: Location) -> Optional[Location]: 14 | """Execution of the Movement Evaluation Policy""" 15 | 16 | return None 17 | -------------------------------------------------------------------------------- /policies/dispatcher/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sebastian-quintero/mdrp-sim/94656681006f7bbc32b2f1970e288944c52d308a/policies/dispatcher/__init__.py -------------------------------------------------------------------------------- /policies/dispatcher/buffering/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sebastian-quintero/mdrp-sim/94656681006f7bbc32b2f1970e288944c52d308a/policies/dispatcher/buffering/__init__.py -------------------------------------------------------------------------------- /policies/dispatcher/buffering/dispatcher_buffering_policy.py: -------------------------------------------------------------------------------- 1 | from policies.policy import Policy 2 | 3 | 4 | class DispatcherBufferingPolicy(Policy): 5 | """Class that establishes how the dispatcher buffers orders before executing a dispatching event""" 6 | 7 | def execute(self, env_time: int) -> bool: 8 | """Implementation of the policy""" 9 | 10 | pass 11 | -------------------------------------------------------------------------------- /policies/dispatcher/buffering/rolling_horizon.py: -------------------------------------------------------------------------------- 1 | from settings import settings 2 | from policies.dispatcher.buffering.dispatcher_buffering_policy import DispatcherBufferingPolicy 3 | 4 | 5 | class RollingBufferingPolicy(DispatcherBufferingPolicy): 6 | """Class containing the policy for the dispatcher to buffer orders""" 7 | 8 | def execute(self, env_time: int) -> bool: 9 | """Execution of the order buffering policy""" 10 | 11 | return env_time % settings.DISPATCHER_ROLLING_HORIZON_TIME == 0 12 | -------------------------------------------------------------------------------- /policies/dispatcher/cancellation/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sebastian-quintero/mdrp-sim/94656681006f7bbc32b2f1970e288944c52d308a/policies/dispatcher/cancellation/__init__.py -------------------------------------------------------------------------------- /policies/dispatcher/cancellation/dispatcher_cancellation_policy.py: -------------------------------------------------------------------------------- 1 | from policies.policy import Policy 2 | 3 | 4 | class DispatcherCancellationPolicy(Policy): 5 | """Class that establishes how the dispatcher decides to cancel an order""" 6 | 7 | def execute(self, courier_id: int) -> bool: 8 | """Implementation of the policy""" 9 | 10 | pass 11 | -------------------------------------------------------------------------------- /policies/dispatcher/cancellation/static.py: -------------------------------------------------------------------------------- 1 | from policies.dispatcher.cancellation.dispatcher_cancellation_policy import DispatcherCancellationPolicy 2 | 3 | 4 | class StaticCancellationPolicy(DispatcherCancellationPolicy): 5 | """Class containing the policy for the dispatcher evaluating canceling an order using a static condition""" 6 | 7 | def execute(self, courier_id: int) -> bool: 8 | """Execution of the Cancellation Policy""" 9 | 10 | return courier_id is None 11 | -------------------------------------------------------------------------------- /policies/dispatcher/matching/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sebastian-quintero/mdrp-sim/94656681006f7bbc32b2f1970e288944c52d308a/policies/dispatcher/matching/__init__.py -------------------------------------------------------------------------------- /policies/dispatcher/matching/dispatcher_matching_policy.py: -------------------------------------------------------------------------------- 1 | from typing import List, Iterable, Tuple 2 | 3 | from actors.courier import Courier 4 | from objects.matching_metric import MatchingMetric 5 | from objects.notification import Notification 6 | from objects.order import Order 7 | from policies.policy import Policy 8 | 9 | 10 | class DispatcherMatchingPolicy(Policy): 11 | """Class that establishes how the dispatcher executes the routing and matching of orders and couriers""" 12 | 13 | def execute( 14 | self, 15 | orders: Iterable[Order], 16 | couriers: Iterable[Courier], 17 | env_time: int 18 | ) -> Tuple[List[Notification], MatchingMetric]: 19 | """Implementation of the policy""" 20 | 21 | pass 22 | -------------------------------------------------------------------------------- /policies/dispatcher/matching/greedy.py: -------------------------------------------------------------------------------- 1 | import time 2 | from typing import List, Tuple 3 | 4 | import numpy as np 5 | from haversine import haversine 6 | 7 | from actors.courier import Courier 8 | from objects.matching_metric import MatchingMetric 9 | from objects.notification import Notification, NotificationType 10 | from objects.order import Order 11 | from objects.route import Route 12 | from objects.stop import Stop, StopType 13 | from policies.dispatcher.matching.dispatcher_matching_policy import DispatcherMatchingPolicy 14 | from services.osrm_service import OSRMService 15 | from settings import settings 16 | 17 | 18 | class GreedyMatchingPolicy(DispatcherMatchingPolicy): 19 | """Class containing the policy for the dispatcher to execute a greedy matching""" 20 | 21 | def execute( 22 | self, 23 | orders: List[Order], 24 | couriers: List[Courier], 25 | env_time: int 26 | ) -> Tuple[List[Notification], MatchingMetric]: 27 | """Implementation of the policy""" 28 | 29 | matching_start_time = time.time() 30 | 31 | idle_couriers = [ 32 | courier 33 | for courier in couriers 34 | if courier.condition == 'idle' and courier.active_route is None 35 | ] 36 | prospects = self._get_prospects(orders, idle_couriers) 37 | estimations = self._get_estimations(orders, idle_couriers, prospects) 38 | 39 | notifications, notified_couriers = [], np.array([]) 40 | if bool(prospects.tolist()) and bool(estimations.tolist()) and bool(orders) and bool(idle_couriers): 41 | for order_ix, order in enumerate(orders): 42 | mask = np.where(np.logical_and( 43 | prospects[:, 0] == order_ix, 44 | np.logical_not(np.isin(prospects[:, 1], notified_couriers)) 45 | )) 46 | 47 | if bool(mask[0].tolist()): 48 | order_prospects = prospects[mask] 49 | order_estimations = estimations[mask] 50 | min_time = order_estimations['time'].min() 51 | selection_mask = np.where(order_estimations['time'] == min_time) 52 | selected_prospect = order_prospects[selection_mask][0] 53 | 54 | notifications.append( 55 | Notification( 56 | courier=couriers[selected_prospect[1]], 57 | type=NotificationType.PICK_UP_DROP_OFF, 58 | instruction=Route( 59 | orders={order.order_id: order}, 60 | stops=[ 61 | Stop( 62 | location=order.pick_up_at, 63 | orders={order.order_id: order}, 64 | position=0, 65 | type=StopType.PICK_UP, 66 | visited=False 67 | ), 68 | Stop( 69 | location=order.drop_off_at, 70 | orders={order.order_id: order}, 71 | position=1, 72 | type=StopType.DROP_OFF, 73 | visited=False 74 | ) 75 | ] 76 | ) 77 | ) 78 | ) 79 | notified_couriers = np.append(notified_couriers, selected_prospect[1]) 80 | 81 | matching_time = time.time() - matching_start_time 82 | 83 | matching_metric = MatchingMetric( 84 | constraints=0, 85 | couriers=len(couriers), 86 | matches=len(notifications), 87 | matching_time=matching_time, 88 | orders=len(orders), 89 | routes=len(orders), 90 | routing_time=0., 91 | variables=0 92 | ) 93 | 94 | return notifications, matching_metric 95 | 96 | @staticmethod 97 | def _get_prospects(orders: List[Order], couriers: List[Courier]) -> np.ndarray: 98 | """Method to obtain the matching prospects between orders and couriers""" 99 | 100 | prospects = [] 101 | for order_ix, order in enumerate(orders): 102 | for courier_ix, courier in enumerate(couriers): 103 | distance_to_pick_up = haversine(courier.location.coordinates, order.pick_up_at.coordinates) 104 | if distance_to_pick_up <= settings.DISPATCHER_PROSPECTS_MAX_DISTANCE: 105 | prospects.append((order_ix, courier_ix)) 106 | 107 | return np.array(prospects) 108 | 109 | @staticmethod 110 | def _get_estimations(orders: List[Order], couriers: List[Courier], prospects: np.ndarray) -> np.ndarray: 111 | """Method to obtain the time estimations from the matching prospects""" 112 | 113 | estimations = [None] * len(prospects) 114 | for ix, (order_ix, courier_ix) in enumerate(prospects): 115 | order, courier = orders[order_ix], couriers[courier_ix] 116 | route = Route( 117 | orders={order.order_id: order}, 118 | stops=[ 119 | Stop( 120 | location=order.pick_up_at, 121 | orders={order.order_id: order}, 122 | position=0, 123 | type=StopType.PICK_UP, 124 | visited=False 125 | ), 126 | Stop( 127 | location=order.drop_off_at, 128 | orders={order.order_id: order}, 129 | position=1, 130 | type=StopType.DROP_OFF, 131 | visited=False 132 | ) 133 | ] 134 | ) 135 | distance, time = OSRMService.estimate_route_properties( 136 | origin=courier.location, 137 | route=route, 138 | vehicle=courier.vehicle 139 | ) 140 | time += (order.pick_up_service_time + order.drop_off_service_time) 141 | estimations[ix] = (distance, time) 142 | 143 | return np.array(estimations, dtype=[('distance', np.float64), ('time', np.float64)]) 144 | -------------------------------------------------------------------------------- /policies/dispatcher/matching/myopic.py: -------------------------------------------------------------------------------- 1 | import math 2 | import time 3 | from collections import defaultdict 4 | from typing import List, Iterable, Optional, Dict, Tuple 5 | 6 | import numpy as np 7 | from geohash import encode 8 | from haversine import haversine 9 | 10 | from actors.courier import Courier 11 | from objects.matching_metric import MatchingMetric 12 | from objects.notification import Notification, NotificationType 13 | from objects.order import Order 14 | from objects.route import Route 15 | from objects.stop import Stop, StopType 16 | from objects.vehicle import Vehicle 17 | from policies.dispatcher.matching.dispatcher_matching_policy import DispatcherMatchingPolicy 18 | from services.optimization_service.graph.graph_builder import GraphBuilder 19 | from services.optimization_service.model.constraints.balance_constraint import BalanceConstraint 20 | from services.optimization_service.model.constraints.courier_assignment_constraint import CourierAssignmentConstraint 21 | from services.optimization_service.model.constraints.route_assignment_constraint import RouteAssignmentConstraint 22 | from services.optimization_service.model.graph_model_builder import GraphOptimizationModelBuilder 23 | from services.optimization_service.model.mip_model_builder import MIPOptimizationModelBuilder 24 | from services.optimization_service.model.optimization_model import SOLUTION_VALUE, OptimizationModel 25 | from services.optimization_service.problem.matching_problem import MatchingProblem 26 | from services.optimization_service.problem.matching_problem_builder import MatchingProblemBuilder 27 | from services.osrm_service import OSRMService 28 | from settings import settings 29 | from utils.datetime_utils import time_to_sec, sec_to_time, time_diff 30 | 31 | GRAPH_MODEL_BUILDER = GraphOptimizationModelBuilder( 32 | sense='max', 33 | model_constraints=[BalanceConstraint()], 34 | optimizer=settings.OPTIMIZER 35 | ) 36 | MIP_MODEL_BUILDER = MIPOptimizationModelBuilder( 37 | sense='max', 38 | model_constraints=[CourierAssignmentConstraint(), RouteAssignmentConstraint()], 39 | optimizer=settings.OPTIMIZER 40 | ) 41 | 42 | 43 | class MyopicMatchingPolicy(DispatcherMatchingPolicy): 44 | """Class containing the policy for the dispatcher to execute routing and matching of orders and couriers""" 45 | 46 | def __init__(self, assignment_updates: bool, prospects: bool, notification_filtering: bool, mip_matcher: bool): 47 | """Initialize the Matching Policy with desired options""" 48 | 49 | self._assignment_updates = assignment_updates 50 | self._prospects = prospects 51 | self._notification_filtering = notification_filtering 52 | self._mip_matcher = mip_matcher 53 | 54 | def execute( 55 | self, 56 | orders: List[Order], 57 | couriers: List[Courier], 58 | env_time: int 59 | ) -> Tuple[List[Notification], MatchingMetric]: 60 | """Implementation of the policy where routes are first calculated and later assigned""" 61 | 62 | routing_start_time = time.time() 63 | routes = self._generate_routes(orders, couriers, env_time) 64 | routing_time = time.time() - routing_start_time 65 | 66 | matching_start_time = time.time() 67 | prospects = self._generate_matching_prospects(routes, couriers, env_time) 68 | 69 | if bool(prospects.tolist()): 70 | costs = self._generate_matching_costs(routes, couriers, prospects, env_time) 71 | problem = MatchingProblemBuilder.build(routes, couriers, prospects, costs) 72 | 73 | if self._mip_matcher: 74 | model = MIP_MODEL_BUILDER.build(problem) 75 | 76 | else: 77 | graph = GraphBuilder.build(problem) 78 | model = GRAPH_MODEL_BUILDER.build(graph) 79 | 80 | solution = model.solve() 81 | notifications = self._process_solution(solution, problem, env_time) 82 | 83 | else: 84 | model = OptimizationModel( 85 | variable_set=np.array([]), 86 | objective=np.array([]), 87 | constraints=[], 88 | sense=0, 89 | optimizer='', 90 | engine_model=None 91 | ) 92 | notifications = [] 93 | 94 | matching_time = time.time() - matching_start_time 95 | 96 | matching_metric = MatchingMetric( 97 | constraints=len(model.constraints), 98 | couriers=len(couriers), 99 | matches=len(notifications), 100 | matching_time=matching_time, 101 | orders=len(orders), 102 | routes=len(routes), 103 | routing_time=routing_time, 104 | variables=len(model.variable_set) 105 | ) 106 | 107 | return notifications, matching_metric 108 | 109 | def _generate_routes(self, orders: Iterable[Order], couriers: Iterable[Courier], env_time: int) -> List[Route]: 110 | """Method to generate routes, also known as bundles""" 111 | 112 | target_size = self._calculate_target_bundle_size(orders, couriers, env_time) 113 | groups = self._group_by_geohash(orders) 114 | routes, processes, single_ods = [], [], [] 115 | 116 | for ods in groups.values(): 117 | if len(ods) > 1: 118 | routes += self._execute_group_routing(ods, couriers, target_size) 119 | 120 | else: 121 | single_ods += ods 122 | 123 | single_routes = [Route.from_order(od) for od in single_ods] 124 | 125 | return routes + single_routes 126 | 127 | def _execute_group_routing(self, orders: List[Order], couriers: Iterable[Courier], target_size: int): 128 | """Method to orchestrate routing orders for a group""" 129 | 130 | courier_routes, courier_ids, num_idle_couriers = [], [], 0 131 | for courier in couriers: 132 | if ( 133 | courier.condition == 'idle' and 134 | haversine(courier.location.coordinates, orders[0].pick_up_at.coordinates) <= 135 | settings.DISPATCHER_PROSPECTS_MAX_DISTANCE 136 | ): 137 | num_idle_couriers += 1 138 | 139 | elif self._assignment_updates and ( 140 | courier.condition == 'picking_up' and 141 | encode( 142 | courier.location.lat, 143 | courier.location.lng, 144 | settings.DISPATCHER_GEOHASH_PRECISION_GROUPING 145 | ) == orders[0].geohash 146 | ): 147 | courier_routes.append(courier.active_route) 148 | courier_ids.append(courier.courier_id) 149 | 150 | routes = self._generate_group_routes( 151 | orders=orders, 152 | target_size=target_size, 153 | courier_routes=courier_routes, 154 | num_idle_couriers=num_idle_couriers, 155 | max_orders=settings.DISPATCHER_PROSPECTS_MAX_ORDERS, 156 | courier_ids=courier_ids 157 | ) 158 | 159 | return routes 160 | 161 | def _generate_matching_prospects(self, routes: List[Route], couriers: List[Courier], env_time: int) -> np.ndarray: 162 | """Method to generate the possible matching prospects""" 163 | 164 | if self._prospects: 165 | return np.array( 166 | [ 167 | (courier_ix, route_ix) 168 | for route_ix, route in enumerate(routes) 169 | for courier_ix, courier in enumerate(couriers) 170 | if self._is_prospect(route, courier, env_time) 171 | ], 172 | dtype=np.int64 173 | ) 174 | 175 | else: 176 | couriers = [courier for courier in couriers if courier.condition == 'idle'] 177 | num_couriers = len(couriers) 178 | num_routes = len(routes) 179 | couriers_indices = np.arange(num_couriers) 180 | routes_indices = np.arange(num_routes) 181 | 182 | return np.array( 183 | np.array(np.meshgrid(couriers_indices, routes_indices)).T.reshape(num_couriers * num_routes, 2), 184 | dtype=np.int64 185 | ) 186 | 187 | @staticmethod 188 | def _generate_group_routes( 189 | orders: Iterable[Order], 190 | target_size: int, 191 | courier_routes: List[Route], 192 | num_idle_couriers: int, 193 | max_orders: Optional[int] = settings.DISPATCHER_PROSPECTS_MAX_ORDERS, 194 | courier_ids: List[int] = None, 195 | ) -> List[Route]: 196 | """Method to generate routes for a specific group using a heuristic""" 197 | 198 | sorted_orders = sorted(orders, key=lambda o: o.ready_time) 199 | number_of_routes = max(num_idle_couriers, math.ceil(len(sorted_orders) / target_size)) 200 | route_size = min(target_size, max_orders) 201 | 202 | initial_routes = [route for route in courier_routes] 203 | initial_orders = [] 204 | for route in initial_routes: 205 | route.add_stops(route_size) 206 | initial_orders.append([order_id for order_id in route.orders.keys()]) 207 | 208 | routes = initial_routes + [Route(num_stops=route_size + 1) for _ in range(number_of_routes)] 209 | single_routes = [] 210 | 211 | for order in sorted_orders: 212 | route_ix_position_time = [] 213 | 214 | for route_ix, route in enumerate(routes): 215 | if len(route.orders) < route_size: 216 | if not bool(route.orders): 217 | cost = route.calculate_time_update( 218 | destination=order.drop_off_at, 219 | origin=order.pick_up_at, 220 | service_time=order.drop_off_service_time 221 | )[Vehicle.MOTORCYCLE] 222 | route_ix_position_time.append((route_ix, 1, cost)) 223 | 224 | else: 225 | for position in range(1, route.num_stops): 226 | origin = route.stops[position - 1] 227 | destination = route.stops[position] 228 | 229 | if bool(origin.orders) and not bool(destination.orders): 230 | cost = route.calculate_time_update( 231 | destination=order.drop_off_at, 232 | origin=origin.location, 233 | service_time=order.drop_off_service_time 234 | )[Vehicle.MOTORCYCLE] 235 | route_ix_position_time.append((route_ix, position, cost)) 236 | 237 | if bool(route_ix_position_time): 238 | sorted_time = sorted(route_ix_position_time, key=lambda t: t[2]) 239 | selected_route, selected_position, _ = sorted_time[0] 240 | routes[selected_route].add_order(order=order, route_position=selected_position) 241 | 242 | else: 243 | single_routes.append(Route.from_order(order)) 244 | 245 | group_routes = [] 246 | for ix, route in enumerate(routes): 247 | route.update_stops() 248 | 249 | if bool(initial_routes) and ix < len(initial_routes): 250 | route.update(processed_order_ids=initial_orders[ix]) 251 | 252 | if bool(route.orders): 253 | route.initial_prospect = courier_ids[ix] 254 | 255 | if bool(route.orders): 256 | group_routes.append(route) 257 | 258 | return group_routes + single_routes 259 | 260 | @staticmethod 261 | def _calculate_target_bundle_size(orders: Iterable[Order], couriers: Iterable[Courier], env_time: int) -> int: 262 | """Method to calculate the target bundle size based on system intensity""" 263 | 264 | num_orders = len([ 265 | order 266 | for order in orders 267 | if time_to_sec(order.ready_time) <= env_time + settings.DISPATCHER_MYOPIC_READY_TIME_SLACK 268 | ]) 269 | num_couriers = len([courier for courier in couriers if courier.condition == 'idle']) 270 | 271 | return max(math.ceil(num_orders / num_couriers), 1) if num_couriers > 0 else 1 272 | 273 | @staticmethod 274 | def _group_by_geohash(orders: Iterable[Order]) -> Dict[str, List[Order]]: 275 | """Method to group orders by geohash, an alternate way to group into stores""" 276 | 277 | groups = defaultdict(list) 278 | for order in orders: 279 | groups[order.geohash] += [order] 280 | 281 | return groups 282 | 283 | @staticmethod 284 | def _is_prospect(route: Route, courier: Courier, env_time: int) -> bool: 285 | """Method to establish if a courier and route are matching prospects""" 286 | 287 | _, time_to_first_stop = OSRMService.estimate_travelling_properties( 288 | origin=courier.location, 289 | destination=route.stops[0].location, 290 | vehicle=courier.vehicle 291 | ) 292 | stops_time_offset = sum( 293 | abs(time_diff( 294 | time_1=sec_to_time(int(env_time + time_to_first_stop + stop.arrive_at[courier.vehicle])), 295 | time_2=stop.calculate_latest_expected_time() 296 | )) 297 | for stop in route.stops 298 | ) 299 | distance_condition = ( 300 | haversine(courier.location.coordinates, route.stops[0].location.coordinates) <= 301 | settings.DISPATCHER_PROSPECTS_MAX_DISTANCE 302 | ) 303 | stop_offset_condition = ( 304 | stops_time_offset <= settings.DISPATCHER_PROSPECTS_MAX_STOP_OFFSET * route.num_stops 305 | if route.time_since_ready(env_time) <= settings.DISPATCHER_PROSPECTS_MAX_READY_TIME 306 | else True 307 | ) 308 | courier_state_condition = ( 309 | courier.condition == 'idle' or 310 | (courier.condition == 'picking_up' and route.initial_prospect == courier.courier_id) 311 | ) 312 | 313 | return distance_condition and stop_offset_condition and courier_state_condition 314 | 315 | @staticmethod 316 | def _generate_matching_costs( 317 | routes: List[Route], 318 | couriers: List[Courier], 319 | prospects: np.ndarray, 320 | env_time: int 321 | ) -> np.ndarray: 322 | """Method to estimate the cost of a possible match, based on the prospects""" 323 | 324 | costs = np.zeros(len(prospects)) 325 | 326 | for ix, (courier_ix, route_ix) in enumerate(prospects): 327 | route, courier = routes[route_ix], couriers[courier_ix] 328 | distance_to_first_stop, time_to_first_stop = OSRMService.estimate_travelling_properties( 329 | origin=courier.location, 330 | destination=route.stops[0].location, 331 | vehicle=courier.vehicle 332 | ) 333 | costs[ix] = ( 334 | len(route.orders) / (time_to_first_stop + route.time[courier.vehicle]) - 335 | time_diff( 336 | time_1=sec_to_time( 337 | int(env_time + time_to_first_stop + route.stops[0].arrive_at[courier.vehicle]) 338 | ), 339 | time_2=max(order.ready_time for order in route.stops[0].orders.values()) 340 | ) * settings.DISPATCHER_DELAY_PENALTY 341 | ) 342 | 343 | return costs 344 | 345 | def _process_solution( 346 | self, 347 | solution: np.ndarray, 348 | matching_problem: MatchingProblem, 349 | env_time: int 350 | ) -> List[Notification]: 351 | """Method to parse the optimizer solution into the notifications""" 352 | 353 | matching_solution = solution[0:len(matching_problem.prospects)] 354 | matched_prospects_ix = np.where(matching_solution >= SOLUTION_VALUE) 355 | matched_prospects = matching_problem.prospects[matched_prospects_ix] 356 | 357 | if not self._notification_filtering: 358 | notifications = [None] * len(matched_prospects) 359 | 360 | for ix, (courier_ix, route_ix) in enumerate(matched_prospects): 361 | courier, route = matching_problem.couriers[courier_ix], matching_problem.routes[route_ix] 362 | instruction = route.stops[1:] if courier.condition == 'picking_up' else route 363 | 364 | notifications[ix] = Notification( 365 | courier=courier, 366 | instruction=instruction, 367 | type=NotificationType.PICK_UP_DROP_OFF 368 | ) 369 | 370 | else: 371 | notifications = [] 372 | 373 | for ix, (courier_ix, route_ix) in enumerate(matched_prospects): 374 | courier, route = matching_problem.couriers[courier_ix], matching_problem.routes[route_ix] 375 | instruction = route.stops[1:] if courier.condition == 'picking_up' else route 376 | notification = Notification( 377 | courier=courier, 378 | instruction=instruction, 379 | type=NotificationType.PICK_UP_DROP_OFF 380 | ) 381 | _, time_to_first_stop = OSRMService.estimate_travelling_properties( 382 | origin=courier.location, 383 | destination=route.stops[0].location, 384 | vehicle=courier.vehicle 385 | ) 386 | 387 | if isinstance(instruction, list) and courier.condition == 'picking_up': 388 | notifications.append(notification) 389 | 390 | elif courier.condition == 'idle': 391 | if route.time_since_ready(env_time) > settings.DISPATCHER_PROSPECTS_MAX_READY_TIME: 392 | notifications.append(notification) 393 | 394 | elif ( 395 | time_to_first_stop <= settings.DISPATCHER_PROSPECTS_MAX_STOP_OFFSET and 396 | time_to_sec(min(order.ready_time for order in route.orders.values())) <= 397 | env_time + settings.DISPATCHER_PROSPECTS_MAX_STOP_OFFSET 398 | ): 399 | notifications.append(notification) 400 | 401 | elif time_to_first_stop > settings.DISPATCHER_PROSPECTS_MAX_STOP_OFFSET: 402 | notifications.append( 403 | Notification( 404 | courier=courier, 405 | instruction=Route( 406 | stops=[Stop(location=route.stops[0].location, type=StopType.PREPOSITION)] 407 | ), 408 | type=NotificationType.PREPOSITIONING 409 | ) 410 | ) 411 | 412 | return notifications 413 | -------------------------------------------------------------------------------- /policies/dispatcher/prepositioning/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sebastian-quintero/mdrp-sim/94656681006f7bbc32b2f1970e288944c52d308a/policies/dispatcher/prepositioning/__init__.py -------------------------------------------------------------------------------- /policies/dispatcher/prepositioning/dispatcher_prepositioning_policy.py: -------------------------------------------------------------------------------- 1 | from typing import Iterable, List 2 | 3 | from actors.courier import Courier 4 | from objects.notification import Notification 5 | from objects.order import Order 6 | from policies.policy import Policy 7 | 8 | 9 | class DispatcherPrepositioningPolicy(Policy): 10 | """Class that establishes how the dispatcher executes prepositioning instructions""" 11 | 12 | def execute(self, orders: Iterable[Order], couriers: Iterable[Courier]) -> List[Notification]: 13 | """Implementation of the policy""" 14 | 15 | pass 16 | -------------------------------------------------------------------------------- /policies/dispatcher/prepositioning/naive.py: -------------------------------------------------------------------------------- 1 | from typing import Iterable, List 2 | 3 | from actors.courier import Courier 4 | from objects.notification import Notification 5 | from objects.order import Order 6 | from policies.policy import Policy 7 | 8 | 9 | class NaivePrepositioningPolicy(Policy): 10 | """Class that establishes how the dispatcher doesn't execute any preposition instructions""" 11 | 12 | def execute(self, orders: Iterable[Order], couriers: Iterable[Courier]) -> List[Notification]: 13 | """Implementation of the Naive Prepositioning policy""" 14 | 15 | return [] 16 | -------------------------------------------------------------------------------- /policies/dispatcher/prepositioning_evaluation/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sebastian-quintero/mdrp-sim/94656681006f7bbc32b2f1970e288944c52d308a/policies/dispatcher/prepositioning_evaluation/__init__.py -------------------------------------------------------------------------------- /policies/dispatcher/prepositioning_evaluation/dispatcher_prepositioning_evaluation_policy.py: -------------------------------------------------------------------------------- 1 | from policies.policy import Policy 2 | 3 | 4 | class DispatcherPrepositioningEvaluationPolicy(Policy): 5 | """Class that establishes when the dispatcher decides to execute prepositioning instructions""" 6 | 7 | def execute(self, env_time: int) -> bool: 8 | """Implementation of the policy""" 9 | 10 | pass 11 | -------------------------------------------------------------------------------- /policies/dispatcher/prepositioning_evaluation/fixed.py: -------------------------------------------------------------------------------- 1 | from settings import settings 2 | from policies.dispatcher.prepositioning_evaluation.dispatcher_prepositioning_evaluation_policy import \ 3 | DispatcherPrepositioningEvaluationPolicy 4 | 5 | 6 | class FixedPrepositioningEvaluationPolicy(DispatcherPrepositioningEvaluationPolicy): 7 | """Class containing the policy for the dispatcher evaluating prepositioning instructions on a fixed interval""" 8 | 9 | def execute(self, env_time: int) -> bool: 10 | """Execution of the Fixed Prepositioning Timing Policy""" 11 | 12 | return env_time % settings.DISPATCHER_PREPOSITIONING_TIME == 0 13 | -------------------------------------------------------------------------------- /policies/policy.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | 4 | class Policy: 5 | """Class that establishes a default policy""" 6 | 7 | def execute(self, *args, **kwargs) -> Any: 8 | """Implementation of the policy""" 9 | 10 | pass 11 | -------------------------------------------------------------------------------- /policies/user/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sebastian-quintero/mdrp-sim/94656681006f7bbc32b2f1970e288944c52d308a/policies/user/__init__.py -------------------------------------------------------------------------------- /policies/user/cancellation/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sebastian-quintero/mdrp-sim/94656681006f7bbc32b2f1970e288944c52d308a/policies/user/cancellation/__init__.py -------------------------------------------------------------------------------- /policies/user/cancellation/random.py: -------------------------------------------------------------------------------- 1 | import random 2 | 3 | from settings import settings 4 | from policies.user.cancellation.user_cancellation_policy import UserCancellationPolicy 5 | 6 | 7 | class RandomCancellationPolicy(UserCancellationPolicy): 8 | """Class containing the policy that decides how a user evaluates canceling an order using a random probability""" 9 | 10 | def execute(self, courier_id: int) -> bool: 11 | """Execution of the Cancellation Policy""" 12 | 13 | if courier_id is None: 14 | return random.random() <= settings.USER_CANCELLATION_PROBABILITY 15 | 16 | return False 17 | -------------------------------------------------------------------------------- /policies/user/cancellation/user_cancellation_policy.py: -------------------------------------------------------------------------------- 1 | from policies.policy import Policy 2 | 3 | 4 | class UserCancellationPolicy(Policy): 5 | """Class that establishes how the user decides to cancel an order""" 6 | 7 | def execute(self, courier_id: int) -> bool: 8 | """Implementation of the policy""" 9 | 10 | pass 11 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | alembic==1.4.3 2 | haversine==2.3.0 3 | numpy==1.19.1 4 | pandas==1.1.1 5 | psycopg2==2.8.6 6 | pulp==2.3.1 7 | python-geohash==0.8.5 8 | simpy==4.0.1 9 | snowflake==0.0.3 10 | snowflake-connector-python==2.2.9 11 | snowflake-sqlalchemy==1.2.3 12 | SQLAlchemy==1.3.19 13 | # gurobipy==9.1.0 -------------------------------------------------------------------------------- /services/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sebastian-quintero/mdrp-sim/94656681006f7bbc32b2f1970e288944c52d308a/services/__init__.py -------------------------------------------------------------------------------- /services/metrics_service.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from datetime import time 3 | from typing import Dict 4 | 5 | import pandas as pd 6 | from sqlalchemy import create_engine, DateTime, Integer, Float, JSON, Time, Boolean, BigInteger 7 | 8 | from actors.dispatcher import Dispatcher 9 | from ddbb.config import get_db_url 10 | from settings import settings, SIMULATION_KEYS, POLICIES_KEYS 11 | from utils.datetime_utils import time_to_str 12 | 13 | 14 | class MetricsService: 15 | """Class that contains the Metrics Service to calculate the output of a simulation""" 16 | 17 | def __init__(self, instance: int): 18 | """Instantiates the class by creating the DDBB connection""" 19 | 20 | self._instance = instance 21 | self._connection = create_engine(get_db_url(), pool_size=20, max_overflow=0, pool_pre_ping=True) 22 | 23 | def calculate_and_save_metrics(self, dispatcher: Dispatcher): 24 | """Method for calculating and saving the simulation metrics""" 25 | 26 | metrics = self._calculate_metrics(dispatcher) 27 | logging.info(f'Instance {self._instance} | Successful metrics calculation.') 28 | 29 | self._save_metrics(metrics) 30 | self._connection.dispose() 31 | logging.info(f'Instance {self._instance} | Successfully saved metrics to DDBB.') 32 | 33 | def _calculate_metrics(self, dispatcher: Dispatcher) -> Dict[str, pd.DataFrame]: 34 | """Method for calculating metrics based on the Dispatcher, after the simulation is finished""" 35 | 36 | settings_dict = { 37 | 'instance_id': self._instance, 38 | 'simulation_settings': { 39 | k: time_to_str(v) if isinstance(v, time) else v 40 | for k, v in settings.attributes.items() 41 | if k in SIMULATION_KEYS 42 | }, 43 | 'simulation_policies': {k: v for k, v in settings.attributes.items() if k in POLICIES_KEYS}, 44 | 'extra_settings': { 45 | k: v 46 | for k, v in settings.attributes.items() 47 | if k not in SIMULATION_KEYS and k not in POLICIES_KEYS 48 | } 49 | } 50 | order_metrics = [ 51 | {**settings_dict, **order.calculate_metrics()} 52 | for order in list(dispatcher.fulfilled_orders.values()) + list(dispatcher.canceled_orders.values()) 53 | ] 54 | courier_metrics = [ 55 | {**settings_dict, **courier.calculate_metrics()} 56 | for courier in dispatcher.logged_off_couriers.values() 57 | ] 58 | matching_optimization_metrics = [ 59 | {**settings_dict, **{'id': ix}, **metric.calculate_metrics()} 60 | for ix, metric in enumerate(dispatcher.matching_metrics) 61 | ] 62 | 63 | return { 64 | 'order_metrics': pd.DataFrame(order_metrics), 65 | 'courier_metrics': pd.DataFrame(courier_metrics), 66 | 'matching_optimization_metrics': pd.DataFrame(matching_optimization_metrics) 67 | } 68 | 69 | def _save_metrics(self, metrics: Dict[str, pd.DataFrame]): 70 | """Method for saving the metrics to de DDBB""" 71 | 72 | order_metrics = metrics['order_metrics'] 73 | courier_metrics = metrics['courier_metrics'] 74 | matching_optimization_metrics = metrics['matching_optimization_metrics'] 75 | order_metrics.to_sql( 76 | name='order_metrics', 77 | con=self._connection, 78 | if_exists='append', 79 | index=False, 80 | dtype={ 81 | 'created_at': DateTime, 82 | 'instance_id': Integer, 83 | 'simulation_settings': JSON, 84 | 'simulation_policies': JSON, 85 | 'extra_settings': JSON, 86 | 'order_id': Integer, 87 | 'placement_time': Time, 88 | 'preparation_time': Time, 89 | 'acceptance_time': Time, 90 | 'in_store_time': Time, 91 | 'ready_time': Time, 92 | 'pick_up_time': Time, 93 | 'drop_off_time': Time, 94 | 'expected_drop_off_time': Time, 95 | 'cancellation_time': Time, 96 | 'dropped_off': Boolean, 97 | 'click_to_door_time': Float, 98 | 'click_to_taken_time': Float, 99 | 'ready_to_door_time': Float, 100 | 'ready_to_pick_up_time': Float, 101 | 'in_store_to_pick_up_time': Float, 102 | 'drop_off_lateness_time': Float, 103 | 'click_to_cancel_time': Float, 104 | } 105 | ) 106 | courier_metrics.to_sql( 107 | name='courier_metrics', 108 | con=self._connection, 109 | if_exists='append', 110 | index=False, 111 | dtype={ 112 | 'created_at': DateTime, 113 | 'instance_id': Integer, 114 | 'simulation_settings': JSON, 115 | 'simulation_policies': JSON, 116 | 'extra_settings': JSON, 117 | 'courier_id': Integer, 118 | 'on_time': Time, 119 | 'off_time': Time, 120 | 'fulfilled_orders': Integer, 121 | 'earnings': Float, 122 | 'utilization_time': Float, 123 | 'accepted_notifications': Integer, 124 | 'guaranteed_compensation': Boolean, 125 | 'courier_utilization': Float, 126 | 'courier_delivery_earnings': Float, 127 | 'courier_compensation': Float, 128 | 'courier_orders_delivered_per_hour': Float, 129 | 'courier_bundles_picked_per_hour': Float, 130 | } 131 | ) 132 | matching_optimization_metrics.to_sql( 133 | name='matching_optimization_metrics', 134 | con=self._connection, 135 | if_exists='append', 136 | index=False, 137 | dtype={ 138 | 'created_at': DateTime, 139 | 'instance_id': Integer, 140 | 'simulation_settings': JSON, 141 | 'simulation_policies': JSON, 142 | 'extra_settings': JSON, 143 | 'id': Integer, 144 | 'orders': Integer, 145 | 'routes': Integer, 146 | 'couriers': Integer, 147 | 'variables': BigInteger, 148 | 'constraints': BigInteger, 149 | 'routing_time': Float, 150 | 'matching_time': Float, 151 | 'matches': Integer, 152 | } 153 | ) 154 | -------------------------------------------------------------------------------- /services/optimization_service/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sebastian-quintero/mdrp-sim/94656681006f7bbc32b2f1970e288944c52d308a/services/optimization_service/__init__.py -------------------------------------------------------------------------------- /services/optimization_service/graph/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sebastian-quintero/mdrp-sim/94656681006f7bbc32b2f1970e288944c52d308a/services/optimization_service/graph/__init__.py -------------------------------------------------------------------------------- /services/optimization_service/graph/graph.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | 3 | import numpy as np 4 | 5 | 6 | @dataclass 7 | class Graph: 8 | """Class that repesents a directed graph""" 9 | 10 | nodes: np.ndarray 11 | arcs: np.ndarray 12 | incidence_matrix: np.ndarray 13 | -------------------------------------------------------------------------------- /services/optimization_service/graph/graph_builder.py: -------------------------------------------------------------------------------- 1 | from typing import Tuple 2 | 3 | import numpy as np 4 | import numpy.lib.recfunctions as rfn 5 | 6 | from services.optimization_service.graph.graph import Graph 7 | from services.optimization_service.problem.matching_problem import MatchingProblem 8 | 9 | 10 | class GraphBuilder: 11 | """Class that enables the construction of a directed graph""" 12 | 13 | @classmethod 14 | def build(cls, matching_problem: MatchingProblem) -> Graph: 15 | """Main method to build the graph""" 16 | 17 | nodes = cls._build_nodes(matching_problem) 18 | arcs = cls._build_arcs(matching_problem) 19 | incidence_matrix = cls._build_incidence_matrix(nodes, arcs) 20 | 21 | return Graph(arcs=arcs, nodes=nodes, incidence_matrix=incidence_matrix) 22 | 23 | @classmethod 24 | def _build_nodes(cls, matching_problem: MatchingProblem) -> np.ndarray: 25 | """Method to build the nodes based on a matching problem""" 26 | 27 | courier_ids, route_ids = cls._get_entities_ids(matching_problem) 28 | 29 | route_demands = np.array(-1 * np.ones(route_ids.shape), dtype=[('demand', ' np.ndarray: 40 | """Method to build the arcs based on a matching problem""" 41 | 42 | courier_ids, route_ids = cls._get_entities_ids(matching_problem) 43 | matching_costs = np.array(matching_problem.costs, dtype=[('c', ' np.ndarray: 63 | """Method to build an incidence matrix based on nodes and arcs""" 64 | 65 | incidence_matrix = np.zeros((len(nodes), len(arcs)), dtype=np.intc) 66 | cols = np.arange(0, len(arcs)) 67 | node_ids = nodes['id'] 68 | i, j, c = arcs['i'], arcs['j'], arcs['c'] 69 | 70 | nodes_sorted = np.argsort(node_ids) 71 | i_ix = np.searchsorted(node_ids[nodes_sorted], i) 72 | j_ix = np.searchsorted(node_ids[nodes_sorted], j) 73 | sources = nodes_sorted[i_ix] 74 | destinations = nodes_sorted[j_ix] 75 | 76 | incidence_matrix[sources, cols] = 1 77 | incidence_matrix[destinations, cols] = -1 78 | 79 | return incidence_matrix 80 | 81 | @staticmethod 82 | def _get_entities_ids(matching_problem: MatchingProblem) -> Tuple[np.ndarray, np.ndarray]: 83 | """Method to extract the unique entities ids from the matching problem""" 84 | 85 | i_dup, j_dup = matching_problem.matching_prospects['i'], matching_problem.matching_prospects['j'] 86 | 87 | courier_ids = np.array(np.unique(i_dup), dtype=[('id', ' np.ndarray: 94 | """Method to build the arcs that depart from the supply node and are directed into courier or route nodes""" 95 | 96 | supply_entities_combinations = np.array( 97 | np.meshgrid(np.array(['supply']), entities_ids['id']) 98 | ).T.reshape(-1, 2) 99 | i = np.array(supply_entities_combinations[:, 0], dtype=[('i', ' List[Union[LpConstraint, Constr]]: 15 | """Expression of the balance constraint""" 16 | 17 | demands = graph.nodes['demand'] 18 | constraints = [None] * len(graph.nodes) 19 | 20 | for n in range(len(graph.nodes)): 21 | out_flow = variable_set[np.where(graph.incidence_matrix[n] == 1)] 22 | in_flow = variable_set[np.where(graph.incidence_matrix[n] == -1)] 23 | constraints[n] = np.sum(out_flow) - np.sum(in_flow) == demands[n] 24 | 25 | return constraints 26 | -------------------------------------------------------------------------------- /services/optimization_service/model/constraints/courier_assignment_constraint.py: -------------------------------------------------------------------------------- 1 | from typing import List, Union 2 | 3 | import numpy as np 4 | from gurobipy import Constr 5 | from pulp import LpConstraint 6 | 7 | from services.optimization_service.model.constraints.model_constraint import ModelConstraint 8 | from services.optimization_service.problem.matching_problem import MatchingProblem 9 | 10 | 11 | class CourierAssignmentConstraint(ModelConstraint): 12 | """Class containing the constraint that limits the assignments per courier""" 13 | 14 | def express(self, problem: MatchingProblem, variable_set: np.ndarray) -> List[Union[LpConstraint, Constr]]: 15 | """Expression of the constraint""" 16 | 17 | unique_couriers = np.unique(problem.matching_prospects['i']) 18 | constraints = [None] * len(unique_couriers) 19 | 20 | for ix, courier in enumerate(unique_couriers): 21 | routes_indices = np.where(problem.matching_prospects['i'] == courier) 22 | courier_routes = variable_set[routes_indices] 23 | constraints[ix] = np.sum(courier_routes) <= 1 24 | 25 | return constraints 26 | -------------------------------------------------------------------------------- /services/optimization_service/model/constraints/model_constraint.py: -------------------------------------------------------------------------------- 1 | from typing import List, Union 2 | 3 | from gurobipy import Constr 4 | from pulp import LpConstraint 5 | 6 | 7 | class ModelConstraint: 8 | """Class that defines how a constraint is expressed for the model""" 9 | 10 | def express(self, *args, **kwargs) -> List[Union[LpConstraint, Constr]]: 11 | """Method to express a model constraint into a standard format""" 12 | 13 | pass 14 | -------------------------------------------------------------------------------- /services/optimization_service/model/constraints/route_assignment_constraint.py: -------------------------------------------------------------------------------- 1 | from typing import List, Union 2 | 3 | import numpy as np 4 | from gurobipy import Constr 5 | from pulp import LpConstraint 6 | 7 | from services.optimization_service.model.constraints.model_constraint import ModelConstraint 8 | from services.optimization_service.problem.matching_problem import MatchingProblem 9 | 10 | 11 | class RouteAssignmentConstraint(ModelConstraint): 12 | """Class containing the constraint that limits the assignments per route""" 13 | 14 | def express(self, problem: MatchingProblem, variable_set: np.ndarray) -> List[Union[LpConstraint, Constr]]: 15 | """Expression of the constraint""" 16 | 17 | unique_routes = np.unique(problem.matching_prospects['j']) 18 | constraints = [None] * len(unique_routes) 19 | 20 | for ix, route in enumerate(unique_routes): 21 | courier_indices = np.where(problem.matching_prospects['j'] == route) 22 | route_couriers = variable_set[courier_indices] 23 | supply_courier = variable_set[len(problem.matching_prospects) + ix] 24 | constraints[ix] = np.sum(route_couriers) + supply_courier == 1 25 | 26 | return constraints 27 | -------------------------------------------------------------------------------- /services/optimization_service/model/graph_model_builder.py: -------------------------------------------------------------------------------- 1 | from typing import Union 2 | 3 | import numpy as np 4 | from gurobipy import Var, Model, GRB 5 | from pulp import LpVariable, LpProblem 6 | 7 | from services.optimization_service.graph.graph import Graph 8 | from services.optimization_service.model.model_builder import OptimizationModelBuilder 9 | 10 | 11 | class GraphOptimizationModelBuilder(OptimizationModelBuilder): 12 | """Class that enables the construction of an optimization model for matching based on a network formulation""" 13 | 14 | def _build_variables(self, graph: Graph, engine_model: Union[LpProblem, Model]) -> np.ndarray: 15 | """Method to build the model decision variables from the graph""" 16 | 17 | i, j = graph.arcs['i'], graph.arcs['j'] 18 | 19 | return np.vectorize(self._build_cont_bool_var, otypes=[np.object])(i, j, engine_model) 20 | 21 | @staticmethod 22 | def _build_objective(graph: Graph, variable_set: np.ndarray) -> np.ndarray: 23 | """Method to build the model's linear objective from the graph""" 24 | 25 | return np.dot(graph.arcs['c'], variable_set) 26 | 27 | def _build_cont_bool_var( 28 | self, 29 | i: np.ndarray, 30 | j: np.ndarray, 31 | engine_model: Union[LpProblem, Model] 32 | ) -> Union[LpVariable, Var]: 33 | """Method to build a continuous boolean variable""" 34 | 35 | if self._optimizer == 'pulp': 36 | var = LpVariable(f'x({i}, {j})', 0, 1) 37 | else: 38 | var = engine_model.addVar(lb=0, ub=1, vtype=GRB.CONTINUOUS, name=f'x({i}, {j})') 39 | 40 | return var 41 | -------------------------------------------------------------------------------- /services/optimization_service/model/mip_model_builder.py: -------------------------------------------------------------------------------- 1 | from typing import Union 2 | 3 | import numpy as np 4 | from pulp import LpVariable, LpBinary, LpProblem 5 | from gurobipy import Model, Var, GRB 6 | 7 | from services.optimization_service.model.model_builder import OptimizationModelBuilder 8 | from services.optimization_service.problem.matching_problem import MatchingProblem 9 | 10 | 11 | class MIPOptimizationModelBuilder(OptimizationModelBuilder): 12 | """Class that enables the construction of an optimization model for matching""" 13 | 14 | def _build_variables(self, problem: MatchingProblem, engine_model: Union[LpProblem, Model]) -> np.ndarray: 15 | """Method to build the model decision variables, which are integer variables""" 16 | 17 | i, j = problem.matching_prospects['i'], problem.matching_prospects['j'] 18 | couriers_routes_vars = np.vectorize(self._build_int_bool_var, otypes=[np.object])(i, j, engine_model) 19 | 20 | unique_routes = np.unique(j) 21 | supply_courier = np.array(['supply']) 22 | supply_routes_combinations = np.array( 23 | np.array(np.meshgrid(supply_courier, unique_routes)).T.reshape(len(supply_courier) * len(unique_routes), 2), 24 | dtype=' np.ndarray: 36 | """Method to build the model's linear objective""" 37 | 38 | couriers_routes_costs = problem.costs 39 | unique_routes = np.unique(problem.matching_prospects['j']) 40 | supply_routes_costs = np.zeros(len(unique_routes)) 41 | costs = np.concatenate((couriers_routes_costs, supply_routes_costs), axis=0) 42 | 43 | return np.dot(variable_set, costs) 44 | 45 | def _build_int_bool_var( 46 | self, 47 | i: np.ndarray, 48 | j: np.ndarray, 49 | engine_model: Union[LpProblem, Model] 50 | ) -> Union[LpVariable, Var]: 51 | """Method to build an integer boolean variable""" 52 | 53 | if self._optimizer == 'pulp': 54 | var = LpVariable(f'x({i}, {j})', 0, 1, LpBinary) 55 | else: 56 | var = engine_model.addVar(lb=0, ub=1, vtype=GRB.BINARY, name=f'x({i}, {j})') 57 | 58 | return var 59 | -------------------------------------------------------------------------------- /services/optimization_service/model/model_builder.py: -------------------------------------------------------------------------------- 1 | from typing import List, Union 2 | 3 | import numpy as np 4 | from gurobipy import Constr, GRB, Model, Env 5 | from pulp import LpConstraint, LpMinimize, LpMaximize, LpProblem 6 | 7 | from services.optimization_service.model.constraints.model_constraint import ModelConstraint 8 | from services.optimization_service.model.optimization_model import OptimizationModel 9 | 10 | 11 | class OptimizationModelBuilder: 12 | """Class that enables the construction of an optimization model for matching""" 13 | 14 | def __init__(self, sense: str, model_constraints: List[ModelConstraint], optimizer: str): 15 | """Instantiates a builder using the desired sense and constraints""" 16 | 17 | self._sense = sense 18 | self._model_constraints = model_constraints 19 | self._optimizer = optimizer 20 | 21 | def build(self, *args) -> OptimizationModel: 22 | """Main method for building an optimization model""" 23 | 24 | if self._optimizer == 'pulp': 25 | sense = LpMinimize if self._sense == 'min' else LpMaximize 26 | engine_model = LpProblem('problem', sense) 27 | 28 | else: 29 | sense = GRB.MINIMIZE if self._sense == 'min' else GRB.MAXIMIZE 30 | env = Env(empty=True) 31 | env.setParam('OutputFlag', 0) 32 | env.start() 33 | engine_model = Model('problem', env=env) 34 | 35 | variable_set = self._build_variables(args[0], engine_model) 36 | objective = self._build_objective(args[0], variable_set) 37 | constraints = self._build_constraints(args[0], variable_set) 38 | 39 | return OptimizationModel( 40 | constraints=constraints, 41 | engine_model=engine_model, 42 | objective=objective, 43 | optimizer=self._optimizer, 44 | sense=sense, 45 | variable_set=variable_set, 46 | ) 47 | 48 | def _build_variables(self, *args, **kwargs) -> np.ndarray: 49 | """Method to build the model decision variables""" 50 | 51 | pass 52 | 53 | def _build_objective(self, *args, **kwargs) -> np.ndarray: 54 | """Method to build the model's linear objective""" 55 | 56 | pass 57 | 58 | def _build_constraints(self, *args, **kwargs) -> List[Union[LpConstraint, Constr]]: 59 | """Method to build the linear constraints using the decision variables""" 60 | 61 | constraints = [] 62 | for model_constraint in self._model_constraints: 63 | constraints += model_constraint.express(*args, **kwargs) 64 | 65 | return constraints 66 | -------------------------------------------------------------------------------- /services/optimization_service/model/optimization_model.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from typing import List, Union, Optional 3 | 4 | import numpy as np 5 | from gurobipy import GRB, Model, Var, Constr 6 | from pulp import LpProblem, LpConstraint, LpVariable, value, PULP_CBC_CMD, LpStatusOptimal 7 | 8 | SOLUTION_VALUE = 0.99 9 | 10 | 11 | @dataclass 12 | class OptimizationModel: 13 | """Class that defines an optimization model to be solved""" 14 | 15 | constraints: List[Union[LpConstraint, Constr]] 16 | engine_model: Optional[Union[LpProblem, Model]] 17 | objective: np.ndarray 18 | optimizer: str 19 | sense: int 20 | variable_set: np.ndarray 21 | 22 | def solve(self): 23 | """Method for solving the optimization model""" 24 | 25 | if self.optimizer == 'pulp': 26 | for constraint in self.constraints: 27 | self.engine_model += constraint 28 | 29 | self.engine_model += self.objective 30 | status = self.engine_model.solve(PULP_CBC_CMD(msg=False)) 31 | solution = ( 32 | np.vectorize(self._var_sol)(self.variable_set) 33 | if status == LpStatusOptimal 34 | else np.array([]) 35 | ) 36 | 37 | else: 38 | for constraint in self.constraints: 39 | self.engine_model.addConstr(constraint) 40 | 41 | self.engine_model.setObjective(self.objective, self.sense) 42 | self.engine_model.optimize() 43 | solution = ( 44 | np.vectorize(self._var_sol)(self.variable_set) 45 | if self.engine_model.status == GRB.OPTIMAL 46 | else np.array([]) 47 | ) 48 | 49 | return solution 50 | 51 | def _var_sol(self, var: Union[LpVariable, Var]) -> float: 52 | """Method to obtain the solution of a decision variable""" 53 | 54 | return value(var) if self.optimizer == 'pulp' else var.x 55 | -------------------------------------------------------------------------------- /services/optimization_service/problem/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sebastian-quintero/mdrp-sim/94656681006f7bbc32b2f1970e288944c52d308a/services/optimization_service/problem/__init__.py -------------------------------------------------------------------------------- /services/optimization_service/problem/matching_problem.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from typing import List 3 | 4 | import numpy as np 5 | 6 | from actors.courier import Courier 7 | from objects.route import Route 8 | 9 | 10 | @dataclass 11 | class MatchingProblem: 12 | """Class that represents a matching problem that must be solved""" 13 | 14 | routes: List[Route] 15 | couriers: List[Courier] 16 | prospects: np.ndarray 17 | matching_prospects: np.ndarray 18 | costs: np.ndarray 19 | -------------------------------------------------------------------------------- /services/optimization_service/problem/matching_problem_builder.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | 3 | import numpy as np 4 | import numpy.lib.recfunctions as rfn 5 | 6 | from actors.courier import Courier 7 | from objects.route import Route 8 | from services.optimization_service.problem.matching_problem import MatchingProblem 9 | 10 | 11 | class MatchingProblemBuilder: 12 | """Class to build a matching problem that must be solved""" 13 | 14 | @classmethod 15 | def build(cls, routes: List[Route], couriers: List[Courier], prospects: np.ndarray, costs: np.ndarray): 16 | """Main method to build a matching problem""" 17 | 18 | return MatchingProblem( 19 | routes=routes, 20 | couriers=couriers, 21 | prospects=prospects, 22 | matching_prospects=cls._build_matching_prospects(routes, couriers, prospects), 23 | costs=costs 24 | ) 25 | 26 | @staticmethod 27 | def _build_matching_prospects(routes: List[Route], couriers: List[Courier], prospects: np.ndarray) -> np.ndarray: 28 | """Method to build prospects that relate id's instead of indices""" 29 | 30 | if not prospects.tolist(): 31 | return np.empty([0]) 32 | 33 | courier_ids = np.array([courier.courier_id for courier in couriers], dtype=[('i', ' Route: 20 | """Method to obtain a movement route using docker-mounted OSRM""" 21 | 22 | lat_0, lng_0 = origin.coordinates 23 | lat_1, lng_1 = destination.coordinates 24 | 25 | url = cls.URL.format(lng_0=lng_0, lat_0=lat_0, lng_1=lng_1, lat_1=lat_1) 26 | 27 | try: 28 | response = requests.get(url, timeout=5) 29 | 30 | if response and response.status_code in [requests.codes.ok, requests.codes.no_content]: 31 | response_data = response.json() 32 | steps = response_data.get('routes', [])[0].get('legs', [])[0].get('steps', []) 33 | 34 | stops = [] 35 | for ix, step in enumerate(steps): 36 | lng, lat = step.get('maneuver', {}).get('location', []) 37 | stop = Stop( 38 | location=Location(lat=lat, lng=lng), 39 | position=ix 40 | ) 41 | stops.append(stop) 42 | 43 | return Route(stops=stops) 44 | 45 | except: 46 | logging.exception('Exception captured in OSRMService.get_route. Check Docker.') 47 | 48 | return Route( 49 | stops=[ 50 | Stop( 51 | location=origin, 52 | position=0 53 | ), 54 | Stop( 55 | location=destination, 56 | position=1 57 | ) 58 | ] 59 | ) 60 | 61 | @classmethod 62 | def estimate_route_properties(cls, origin: Location, route: Route, vehicle: Vehicle) -> Tuple[float, float]: 63 | """Method to estimate the distance and time it would take to fulfill a route from an origin""" 64 | 65 | complete_route = Route( 66 | stops=[ 67 | Stop(location=origin, position=0) 68 | ] + [ 69 | Stop(location=stop.location, position=ix + 1) 70 | for ix, stop in enumerate(route.stops) 71 | ] 72 | ) 73 | 74 | route_distance, route_time = 0, 0 75 | 76 | try: 77 | for ix in range(len(complete_route.stops) - 1): 78 | distance, time = cls.estimate_travelling_properties( 79 | origin=complete_route.stops[ix].location, 80 | destination=complete_route.stops[ix + 1].location, 81 | vehicle=vehicle 82 | ) 83 | route_distance += distance 84 | route_time += time 85 | 86 | except: 87 | logging.exception('Exception captured in OSRMService.estimate_route_properties. Check Docker.') 88 | 89 | return route_distance, route_time 90 | 91 | @classmethod 92 | def estimate_travelling_properties( 93 | cls, 94 | origin: Location, 95 | destination: Location, 96 | vehicle: Vehicle 97 | ) -> Tuple[float, float]: 98 | """Method to estimate the distance and time it takes to go from an origin to a destination""" 99 | 100 | route_distance, route_time = 0, 0 101 | 102 | try: 103 | travelling_route = cls.get_route(origin=origin, destination=destination) 104 | 105 | except: 106 | logging.exception('Exception captured in OSRMService.estimate_travelling_properties. Check Docker.') 107 | travelling_route = Route( 108 | stops=[ 109 | Stop( 110 | location=origin, 111 | position=0 112 | ), 113 | Stop( 114 | location=destination, 115 | position=1 116 | ) 117 | ] 118 | ) 119 | 120 | for travelling_ix in range(len(travelling_route.stops) - 1): 121 | distance = haversine( 122 | point1=travelling_route.stops[travelling_ix].location.coordinates, 123 | point2=travelling_route.stops[travelling_ix + 1].location.coordinates 124 | ) 125 | time = int(distance / vehicle.average_velocity) 126 | 127 | route_distance += distance 128 | route_time += time 129 | 130 | return route_distance, route_time 131 | 132 | @classmethod 133 | def update_estimate_time_for_vehicles( 134 | cls, 135 | origin: Location, 136 | destination: Location, 137 | time: Dict[Any, float], 138 | service_time: float 139 | ): 140 | """Method to estimate route times for vehicles""" 141 | 142 | for v in time.keys(): 143 | try: 144 | _, time_estimation = cls.estimate_travelling_properties( 145 | origin=origin, 146 | destination=destination, 147 | vehicle=v 148 | ) 149 | 150 | except: 151 | time_estimation = 0 152 | 153 | time[v] += time_estimation + service_time 154 | -------------------------------------------------------------------------------- /settings.py: -------------------------------------------------------------------------------- 1 | from datetime import time 2 | from typing import Dict, Any 3 | 4 | from utils.datetime_utils import min_to_sec, hour_to_sec 5 | 6 | DB_USERNAME: str = 'docker' # DDBB Username 7 | DB_PASSWORD: str = 'docker' # DDBB Password 8 | DB_HOST: str = '127.0.0.1' # DDBB Host 9 | DB_PORT: str = '5432' # DDBB Port 10 | DB_DATABASE: str = 'mdrp_sim' # DDBB Name 11 | 12 | 13 | class Settings: 14 | def __init__(self, attributes: Dict[str, Any] = None): 15 | self._attributes = attributes 16 | 17 | @property 18 | def attributes(self) -> Dict[str, Any]: 19 | return self._attributes 20 | 21 | def __getattr__(self, attr): 22 | return self._attributes.get(attr) 23 | 24 | 25 | SIMULATION_KEYS = [ 26 | 'SIMULATE_FROM', 27 | 'SIMULATE_UNTIL', 28 | 'CREATE_USERS_FROM', 29 | 'CREATE_USERS_UNTIL', 30 | 'CREATE_COURIERS_FROM', 31 | 'CREATE_COURIERS_UNTIL', 32 | 'WARM_UP_TIME' 33 | ] 34 | 35 | POLICIES_KEYS = [ 36 | 'DISPATCHER_CANCELLATION_POLICY', 37 | 'DISPATCHER_BUFFERING_POLICY', 38 | 'DISPATCHER_PREPOSITIONING_EVALUATION_POLICY', 39 | 'DISPATCHER_PREPOSITIONING_POLICY', 40 | 'DISPATCHER_MATCHING_POLICY', 41 | 'COURIER_ACCEPTANCE_POLICY', 42 | 'COURIER_MOVEMENT_EVALUATION_POLICY', 43 | 'COURIER_MOVEMENT_POLICY', 44 | 'USER_CANCELLATION_POLICY' 45 | ] 46 | 47 | # Settings for the simulation 48 | settings = Settings({ 49 | # Project 50 | # --- List[int] = Desired instances to be simulated 51 | 'INSTANCES': [3], 52 | # --- bool = Enable / Disable specific (verbose) actor and policy logs 53 | 'VERBOSE_LOGS': False, 54 | # --- Optional[Union[float, int]] = Seed for running the simulation. Can be None. 55 | 'SEED': 8795, 56 | # str = Optimizer to use. Options: ['pulp', 'gurobi'] 57 | 'OPTIMIZER': 'pulp', 58 | 59 | # Simulation Constants 60 | # --- time = Simulate from this time on 61 | 'SIMULATE_FROM': time(0, 0, 0), 62 | # --- time = Simulate until this time 63 | 'SIMULATE_UNTIL': time(10, 0, 0), 64 | # --- time = Create new users to submit orders from this time 65 | 'CREATE_USERS_FROM': time(9, 0, 0), 66 | # --- time = Create new users to submit orders until this time 67 | 'CREATE_USERS_UNTIL': time(9, 5, 0), 68 | # --- time = Create new couriers to log on from this time 69 | 'CREATE_COURIERS_FROM': time(0, 0, 0), 70 | # --- time = Create new couriers to log on until this time 71 | 'CREATE_COURIERS_UNTIL': time(0, 5, 0), 72 | # --- float = Warm up time [sec] to achieve steady state simulation 73 | 'WARM_UP_TIME': hour_to_sec(3) + min_to_sec(0), 74 | 75 | # Simulation Policies - Dispatcher 76 | # --- str = Policy for canceling orders. Options: ['static'] 77 | 'DISPATCHER_CANCELLATION_POLICY': 'static', 78 | # --- str = Policy for buffering orders. Options: ['rolling_horizon'] 79 | 'DISPATCHER_BUFFERING_POLICY': 'rolling_horizon', 80 | # --- str = Policy for deciding when to evaluate prepositioning. Options: ['fixed'] 81 | 'DISPATCHER_PREPOSITIONING_EVALUATION_POLICY': 'fixed', 82 | # --- str = Policy for executing prepositioning. Options: ['naive'] 83 | 'DISPATCHER_PREPOSITIONING_POLICY': 'naive', 84 | # --- str = Policy for matching. Options: ['greedy', 'mdrp', 'mdrp_graph', 'mdrp_graph_prospects', 'modified_mdrp'] 85 | 'DISPATCHER_MATCHING_POLICY': 'mdrp', 86 | 87 | # Simulation Policies - Courier 88 | # --- str = Policy for accepting a notification. Options: ['uniform', 'absolute'] 89 | 'COURIER_ACCEPTANCE_POLICY': 'uniform', 90 | # --- str = Policy to determine if the courier wants to relocate. Options: ['neighbors', 'still'] 91 | 'COURIER_MOVEMENT_EVALUATION_POLICY': 'neighbors', 92 | # --- str = Policy that models the movement of a courier about the city. Options: ['osrm'] 93 | 'COURIER_MOVEMENT_POLICY': 'osrm', 94 | 95 | # Simulation Policies - User 96 | # --- str = Policy to decide if a user wants to cancel an order. Options: ['random'] 97 | 'USER_CANCELLATION_POLICY': 'random', 98 | 99 | # Simulation Policies Configuration - Dispatcher - Cancellation Policy 100 | # float = Time [sec] to cancel an order 101 | 'DISPATCHER_WAIT_TO_CANCEL': min_to_sec(60), 102 | 103 | # Simulation Policies Configuration - Dispatcher - Buffering Policy 104 | # float = Time [sec] to buffer orders 105 | 'DISPATCHER_ROLLING_HORIZON_TIME': min_to_sec(2), 106 | 107 | # Simulation Policies Configuration - Dispatcher - Prepositioning Evaluation Policy 108 | # float = Time [sec] to execute prepositioning 109 | 'DISPATCHER_PREPOSITIONING_TIME': hour_to_sec(1), 110 | 111 | # Simulation Policies Configuration - Dispatcher - Matching Policy 112 | # float = Maximum distance [km] between a courier and a store 113 | 'DISPATCHER_PROSPECTS_MAX_DISTANCE': 3, 114 | # int = Maximum number of orders a courier can carry simultaneously 115 | 'DISPATCHER_PROSPECTS_MAX_ORDERS': 3, 116 | # float = Max offset time [sec] from a stop's expected timestamp 117 | 'DISPATCHER_PROSPECTS_MAX_STOP_OFFSET': min_to_sec(10), 118 | # float = Time [sec] for ready route before avoiding stop offset 119 | 'DISPATCHER_PROSPECTS_MAX_READY_TIME': min_to_sec(4), 120 | # int = Time [sec] to consider ready orders for target bundle size 121 | 'DISPATCHER_MYOPIC_READY_TIME_SLACK': min_to_sec(10), 122 | # int = Precision to group orders into a proxy of stores 123 | 'DISPATCHER_GEOHASH_PRECISION_GROUPING': 8, 124 | # float = Constant penalty for delays in the pick up of a bundle of orders 125 | 'DISPATCHER_DELAY_PENALTY': 0.4, 126 | 127 | # Simulation Policies Configuration - Courier - Acceptance Policy 128 | # --- float = Minimum acceptance rate for any courier 129 | 'COURIER_MIN_ACCEPTANCE_RATE': 0.4, 130 | # --- float = Time [sec] that a courier waits before accepting / rejecting a notification 131 | 'COURIER_WAIT_TO_ACCEPT': 20, 132 | 133 | # Simulation Policies Configuration - Courier - Movement Evaluation Policy 134 | # float = Probability that a courier WILL move 135 | 'COURIER_MOVEMENT_PROBABILITY': 0.4, 136 | # float = Time [sec] that a courier waits before deciding to move 137 | 'COURIER_WAIT_TO_MOVE': min_to_sec(45), 138 | 139 | # Simulation Policies Configuration - Courier - Other Constants 140 | # float = Money earned for dropping off an order 141 | 'COURIER_EARNINGS_PER_ORDER': 3, 142 | # float = Rate at which the courier can be compensated per hour 143 | 'COURIER_EARNINGS_PER_HOUR': 8, 144 | 145 | # Simulation Policies Configuration - User - Cancellation Policy 146 | # float = Time [sec] that a user waits before deciding to cancel an order 147 | 'USER_WAIT_TO_CANCEL': min_to_sec(45), 148 | # float = Probability that a user will cancel an order if no courier is assigned 149 | 'USER_CANCELLATION_PROBABILITY': 0.75, 150 | 151 | # Object Constants 152 | # int = Maximum service time [sec] at the pick up location 153 | 'ORDER_MAX_PICK_UP_SERVICE_TIME': min_to_sec(10), 154 | # int = Maximum service time [sec] at the drop off location 155 | 'ORDER_MAX_DROP_OFF_SERVICE_TIME': min_to_sec(5), 156 | # int = Minimum service time [sec] at either a pick up or drop off location 157 | 'ORDER_MIN_SERVICE_TIME': min_to_sec(2), 158 | # float = Target time [sec] in which an order should be delivered 159 | 'ORDER_TARGET_DROP_OFF_TIME': min_to_sec(40), 160 | 161 | }) 162 | -------------------------------------------------------------------------------- /simulate.py: -------------------------------------------------------------------------------- 1 | import random 2 | 3 | from simpy import Environment 4 | 5 | from settings import settings 6 | from actors.world import World 7 | from services.metrics_service import MetricsService 8 | from utils.datetime_utils import time_to_sec 9 | from utils.logging_utils import configure_logs 10 | 11 | if __name__ == '__main__': 12 | """Main method for running the mdrp-sim""" 13 | 14 | configure_logs() 15 | 16 | for instance in settings.INSTANCES: 17 | random.seed(settings.SEED) 18 | 19 | env = Environment(initial_time=time_to_sec(settings.SIMULATE_FROM)) 20 | world = World(env=env, instance=instance) 21 | env.run(until=time_to_sec(settings.SIMULATE_UNTIL)) 22 | world.post_process() 23 | 24 | metrics_service = MetricsService(instance=instance) 25 | metrics_service.calculate_and_save_metrics(world.dispatcher) 26 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sebastian-quintero/mdrp-sim/94656681006f7bbc32b2f1970e288944c52d308a/tests/__init__.py -------------------------------------------------------------------------------- /tests/functional/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sebastian-quintero/mdrp-sim/94656681006f7bbc32b2f1970e288944c52d308a/tests/functional/__init__.py -------------------------------------------------------------------------------- /tests/functional/actors/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sebastian-quintero/mdrp-sim/94656681006f7bbc32b2f1970e288944c52d308a/tests/functional/actors/__init__.py -------------------------------------------------------------------------------- /tests/functional/actors/tests_user.py: -------------------------------------------------------------------------------- 1 | import random 2 | import unittest 3 | from datetime import time, timedelta, datetime, date 4 | from unittest.mock import patch 5 | 6 | from simpy import Environment 7 | 8 | from settings import settings 9 | from actors.dispatcher import Dispatcher 10 | from actors.user import User 11 | from objects.location import Location 12 | from policies.user.cancellation.random import RandomCancellationPolicy 13 | from tests.functional.actors.tests_dispatcher import TestsDispatcher, DummyMatchingPolicy 14 | from utils.datetime_utils import min_to_sec, hour_to_sec 15 | 16 | 17 | class TestsUser(unittest.TestCase): 18 | """Tests for the User actor class""" 19 | 20 | # Order properties to be reused 21 | order_id = 0 22 | pick_up_at = Location(lat=4.689697, lng=-74.055495) 23 | drop_off_at = Location(lat=4.690296, lng=-74.043929) 24 | placement_time = time(12, 0, 0) 25 | expected_drop_off_time = time(12, 40, 0) 26 | preparation_time = time(12, 1, 0) 27 | ready_time = time(12, 11, 0) 28 | 29 | # Services to be reused 30 | cancellation_policy = RandomCancellationPolicy() 31 | 32 | @patch('settings.settings.USER_WAIT_TO_CANCEL', min_to_sec(5)) 33 | @patch('settings.settings.USER_CANCELLATION_PROBABILITY', 0.99) 34 | @patch('settings.settings.DISPATCHER_WAIT_TO_CANCEL', min_to_sec(50)) 35 | def test_submit_cancel_order(self): 36 | """Test to verify how a user submits and decides to cancel an order""" 37 | 38 | # Constants 39 | random.seed(666) 40 | initial_time = hour_to_sec(12) 41 | time_delta = min_to_sec(10) 42 | 43 | # Services 44 | env = Environment(initial_time=initial_time) 45 | dispatcher = Dispatcher(env=env, matching_policy=DummyMatchingPolicy()) 46 | 47 | # Create a user and have it submit an order immediately 48 | user = User(cancellation_policy=self.cancellation_policy, dispatcher=dispatcher, env=env) 49 | user.submit_order_event( 50 | order_id=self.order_id, 51 | pick_up_at=self.pick_up_at, 52 | drop_off_at=self.drop_off_at, 53 | placement_time=self.placement_time, 54 | expected_drop_off_time=self.expected_drop_off_time, 55 | preparation_time=self.preparation_time, 56 | ready_time=self.ready_time 57 | ) 58 | env.run(until=initial_time + time_delta) 59 | 60 | # Verify order is created and canceled due to a courier not being assigned 61 | self.assertTrue(user.order) 62 | self.assertIsNone(user.order.courier_id) 63 | self.assertIsNotNone(user.order.cancellation_time) 64 | self.assertEqual(dispatcher.unassigned_orders, {}) 65 | self.assertIn(self.order_id, dispatcher.canceled_orders.keys()) 66 | self.assertEqual( 67 | user.order.cancellation_time, 68 | ( 69 | datetime.combine(date.today(), self.placement_time) + 70 | timedelta(seconds=settings.USER_WAIT_TO_CANCEL) 71 | ).time() 72 | ) 73 | self.assertEqual(user.condition, 'canceled') 74 | 75 | @patch('settings.settings.USER_WAIT_TO_CANCEL', min_to_sec(40)) 76 | @patch('settings.settings.DISPATCHER_WAIT_TO_CANCEL', min_to_sec(50)) 77 | def test_submit_courier_assigned(self): 78 | """Test to verify how a user submits and order and doesn't cancel since a courier is assigned""" 79 | 80 | # Constants 81 | random.seed(666) 82 | 83 | # Services 84 | env = Environment(initial_time=hour_to_sec(12)) 85 | dispatcher = Dispatcher(env=env, matching_policy=DummyMatchingPolicy()) 86 | 87 | # Create a user, have it submit an order immediately and after some minutes, assign a courier 88 | user = User(cancellation_policy=self.cancellation_policy, dispatcher=dispatcher, env=env) 89 | user.submit_order_event( 90 | order_id=self.order_id, 91 | pick_up_at=self.pick_up_at, 92 | drop_off_at=self.drop_off_at, 93 | placement_time=self.placement_time, 94 | expected_drop_off_time=self.expected_drop_off_time, 95 | preparation_time=self.preparation_time, 96 | ready_time=self.ready_time 97 | ) 98 | env.process(TestsDispatcher.assign_courier(user, env, dispatcher)) 99 | env.run(until=hour_to_sec(13)) 100 | 101 | # Verify order is created but not canceled because a courier was assigned 102 | self.assertTrue(user.order) 103 | self.assertIsNotNone(user.order.courier_id) 104 | self.assertIsNone(user.order.cancellation_time) 105 | self.assertEqual(dispatcher.assigned_orders, {self.order_id: user.order}) 106 | self.assertEqual(dispatcher.unassigned_orders, {}) 107 | self.assertEqual(user.condition, 'waiting') 108 | 109 | @patch('settings.settings.USER_CANCELLATION_PROBABILITY', 0) 110 | @patch('settings.settings.DISPATCHER_WAIT_TO_CANCEL', min_to_sec(120)) 111 | def test_submit_wait_for_order(self, *args): 112 | """Test to verify how a user submits an order but doesn't cancel even without courier, deciding to wait""" 113 | 114 | # Constants 115 | random.seed(157) 116 | 117 | # Services 118 | env = Environment(initial_time=hour_to_sec(12)) 119 | dispatcher = Dispatcher(env=env, matching_policy=DummyMatchingPolicy()) 120 | 121 | # Create a user and have it submit an order immediately 122 | user = User(cancellation_policy=self.cancellation_policy, dispatcher=dispatcher, env=env) 123 | user.submit_order_event( 124 | order_id=self.order_id, 125 | pick_up_at=self.pick_up_at, 126 | drop_off_at=self.drop_off_at, 127 | placement_time=self.placement_time, 128 | expected_drop_off_time=self.expected_drop_off_time, 129 | preparation_time=self.preparation_time, 130 | ready_time=self.ready_time 131 | ) 132 | env.run(until=hour_to_sec(13)) 133 | 134 | # Verify order is created but not canceled, disregarding the lack of a courier 135 | self.assertTrue(user.order) 136 | self.assertIsNone(user.order.courier_id) 137 | self.assertIsNone(user.order.cancellation_time) 138 | self.assertEqual(dispatcher.unassigned_orders, {self.order_id: user.order}) 139 | self.assertEqual(user.condition, 'waiting') 140 | -------------------------------------------------------------------------------- /tests/functional/objects/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sebastian-quintero/mdrp-sim/94656681006f7bbc32b2f1970e288944c52d308a/tests/functional/objects/__init__.py -------------------------------------------------------------------------------- /tests/functional/objects/tests_route.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from unittest.mock import patch 3 | 4 | from objects.location import Location 5 | from objects.order import Order 6 | from objects.route import Route 7 | from objects.stop import Stop, StopType 8 | from tests.test_utils import mocked_get_route 9 | 10 | 11 | class TestsRoute(unittest.TestCase): 12 | """Class for the Route object class""" 13 | 14 | @patch('services.osrm_service.OSRMService.get_route', side_effect=mocked_get_route) 15 | def test_update_route(self, osrm): 16 | """Test to verify a route is updated based on canceled orders""" 17 | 18 | # Constants 19 | order_1 = Order( 20 | order_id=1, 21 | pick_up_at=Location(lat=4.567, lng=1.234), 22 | drop_off_at=Location(lat=1.234, lng=4.567) 23 | ) 24 | order_2 = Order( 25 | order_id=2, 26 | pick_up_at=Location(lat=4.567, lng=1.234), 27 | drop_off_at=Location(lat=1.234, lng=4.567) 28 | ) 29 | 30 | # Test 1: define a route and some orders being canceled 31 | orders_dict = { 32 | order_1.order_id: order_1, 33 | order_2.order_id: order_2 34 | } 35 | route = Route( 36 | orders=orders_dict, 37 | stops=[ 38 | Stop( 39 | orders={order_1.order_id: order_1}, 40 | type=StopType.PICK_UP, 41 | position=0, 42 | location=order_1.pick_up_at 43 | ), 44 | Stop( 45 | orders={order_2.order_id: order_2}, 46 | type=StopType.PICK_UP, 47 | position=1, 48 | location=order_2.pick_up_at 49 | ), 50 | Stop( 51 | orders={order_1.order_id: order_1}, 52 | type=StopType.DROP_OFF, 53 | position=2, 54 | location=order_1.drop_off_at 55 | ), 56 | Stop( 57 | orders={order_2.order_id: order_2}, 58 | type=StopType.DROP_OFF, 59 | position=3, 60 | location=order_2.drop_off_at 61 | ) 62 | ] 63 | ) 64 | canceled_order_ids = [1] 65 | 66 | # Update the route and assert canceled orders were removed 67 | route.update(canceled_order_ids) 68 | self.assertEqual(len(route.orders), 1) 69 | self.assertEqual(len(route.stops), 2) 70 | for stop in route.stops: 71 | self.assertNotIn(order_1.order_id, stop.orders) 72 | self.assertEqual(len(stop.orders), 1) 73 | 74 | # Test 2: define a route and all orders being canceled 75 | orders_dict = { 76 | order_1.order_id: order_1, 77 | order_2.order_id: order_2 78 | } 79 | route = Route( 80 | orders=orders_dict, 81 | stops=[ 82 | Stop( 83 | orders=orders_dict, 84 | type=StopType.PICK_UP, 85 | position=0, 86 | location=order_1.pick_up_at 87 | ), 88 | Stop( 89 | orders=orders_dict, 90 | type=StopType.DROP_OFF, 91 | position=2, 92 | location=order_1.drop_off_at 93 | ) 94 | ] 95 | ) 96 | canceled_order_ids = [1, 2] 97 | 98 | # Update the route and assert canceled orders were removed 99 | route.update(canceled_order_ids) 100 | self.assertEqual(len(route.orders), 0) 101 | self.assertEqual(len(route.stops), 0) 102 | 103 | @patch('services.osrm_service.OSRMService.get_route', side_effect=mocked_get_route) 104 | def test_add_order(self, osrm): 105 | """Test to verify how a new order is added to an existing route""" 106 | 107 | # Constants 108 | old_order = Order( 109 | order_id=5, 110 | pick_up_at=Location(lat=4.567, lng=1.234), 111 | drop_off_at=Location(lat=1.234, lng=4.567) 112 | ) 113 | new_order = Order( 114 | order_id=1, 115 | pick_up_at=Location(lat=1.234, lng=4.567), 116 | drop_off_at=Location(lat=4.567, lng=1.234) 117 | ) 118 | 119 | # Case 1: the route is empty 120 | route = Route(num_stops=2) 121 | route.add_order(new_order) 122 | self.assertTrue(route.stops) 123 | self.assertEqual(len(route.stops), 2) 124 | self.assertEqual(len(route.stops), route.num_stops) 125 | self.assertIn(new_order.order_id, route.orders.keys()) 126 | self.assertIn(new_order.order_id, route.stops[0].orders.keys()) 127 | self.assertIn(new_order.order_id, route.stops[1].orders.keys()) 128 | self.assertEqual(route.stops[0].type, StopType.PICK_UP) 129 | self.assertEqual(route.stops[1].type, StopType.DROP_OFF) 130 | self.assertTrue(route.time) 131 | 132 | # Case 2. the route has an order and is inserted at correct position 133 | route = Route( 134 | orders={old_order.order_id: old_order}, 135 | stops=[ 136 | Stop( 137 | location=old_order.pick_up_at, 138 | orders={old_order.order_id: old_order}, 139 | position=0, 140 | type=StopType.PICK_UP 141 | ), 142 | Stop( 143 | location=old_order.drop_off_at, 144 | orders={old_order.order_id: old_order}, 145 | position=1, 146 | type=StopType.DROP_OFF 147 | ) 148 | ] 149 | ) 150 | route.add_order(new_order, route_position=2) 151 | self.assertTrue(route) 152 | self.assertEqual(len(route.stops), 3) 153 | self.assertEqual(len(route.stops), route.num_stops) 154 | self.assertIn(new_order.order_id, route.orders.keys()) 155 | self.assertIn(old_order.order_id, route.orders.keys()) 156 | self.assertIn(new_order.order_id, route.stops[0].orders.keys()) 157 | self.assertIn(old_order.order_id, route.stops[0].orders.keys()) 158 | self.assertIn(old_order.order_id, route.stops[1].orders.keys()) 159 | self.assertIn(new_order.order_id, route.stops[2].orders.keys()) 160 | self.assertEqual(route.stops[0].type, StopType.PICK_UP) 161 | self.assertEqual(route.stops[1].type, StopType.DROP_OFF) 162 | self.assertEqual(route.stops[2].type, StopType.DROP_OFF) 163 | self.assertTrue(route.time) 164 | 165 | # Case 3. the route has an order and is inserted at wrong position (greater position) 166 | route = Route( 167 | orders={old_order.order_id: old_order}, 168 | stops=[ 169 | Stop( 170 | location=old_order.pick_up_at, 171 | orders={old_order.order_id: old_order}, 172 | position=0, 173 | type=StopType.PICK_UP 174 | ), 175 | Stop( 176 | location=old_order.drop_off_at, 177 | orders={old_order.order_id: old_order}, 178 | position=1, 179 | type=StopType.DROP_OFF 180 | ) 181 | ] 182 | ) 183 | route.add_order(new_order, route_position=6) 184 | self.assertTrue(route) 185 | self.assertEqual(len(route.stops), 3) 186 | self.assertEqual(len(route.stops), route.num_stops) 187 | self.assertIn(new_order.order_id, route.orders.keys()) 188 | self.assertIn(old_order.order_id, route.orders.keys()) 189 | self.assertIn(new_order.order_id, route.stops[0].orders.keys()) 190 | self.assertIn(old_order.order_id, route.stops[0].orders.keys()) 191 | self.assertIn(old_order.order_id, route.stops[1].orders.keys()) 192 | self.assertIn(new_order.order_id, route.stops[2].orders.keys()) 193 | self.assertEqual(route.stops[0].type, StopType.PICK_UP) 194 | self.assertEqual(route.stops[1].type, StopType.DROP_OFF) 195 | self.assertEqual(route.stops[2].type, StopType.DROP_OFF) 196 | self.assertTrue(route.time) 197 | 198 | # Case 4. the route has an order and is inserted at wrong position (equal position) 199 | route = Route( 200 | orders={old_order.order_id: old_order}, 201 | stops=[ 202 | Stop( 203 | location=old_order.pick_up_at, 204 | orders={old_order.order_id: old_order}, 205 | position=0, 206 | type=StopType.PICK_UP 207 | ), 208 | Stop( 209 | location=old_order.drop_off_at, 210 | orders={old_order.order_id: old_order}, 211 | position=1, 212 | type=StopType.DROP_OFF 213 | ) 214 | ] 215 | ) 216 | route.add_order(new_order, route_position=1) 217 | self.assertTrue(route) 218 | self.assertEqual(len(route.stops), 3) 219 | self.assertEqual(len(route.stops), route.num_stops) 220 | self.assertIn(new_order.order_id, route.orders.keys()) 221 | self.assertIn(old_order.order_id, route.orders.keys()) 222 | self.assertIn(new_order.order_id, route.stops[0].orders.keys()) 223 | self.assertIn(old_order.order_id, route.stops[0].orders.keys()) 224 | self.assertIn(old_order.order_id, route.stops[1].orders.keys()) 225 | self.assertIn(new_order.order_id, route.stops[2].orders.keys()) 226 | self.assertEqual(route.stops[0].type, StopType.PICK_UP) 227 | self.assertEqual(route.stops[1].type, StopType.DROP_OFF) 228 | self.assertEqual(route.stops[2].type, StopType.DROP_OFF) 229 | self.assertTrue(route.time) 230 | 231 | # Case 5. the route has an order and is inserted at wrong position (smaller position) 232 | route = Route( 233 | orders={old_order.order_id: old_order}, 234 | stops=[ 235 | Stop( 236 | location=old_order.pick_up_at, 237 | orders={old_order.order_id: old_order}, 238 | position=0, 239 | type=StopType.PICK_UP 240 | ), 241 | Stop( 242 | location=old_order.drop_off_at, 243 | orders={old_order.order_id: old_order}, 244 | position=1, 245 | type=StopType.DROP_OFF 246 | ) 247 | ] 248 | ) 249 | route.add_order(new_order, route_position=1) 250 | self.assertTrue(route) 251 | self.assertEqual(len(route.stops), 3) 252 | self.assertEqual(len(route.stops), route.num_stops) 253 | self.assertIn(new_order.order_id, route.orders.keys()) 254 | self.assertIn(old_order.order_id, route.orders.keys()) 255 | self.assertIn(new_order.order_id, route.stops[0].orders.keys()) 256 | self.assertIn(old_order.order_id, route.stops[0].orders.keys()) 257 | self.assertIn(old_order.order_id, route.stops[1].orders.keys()) 258 | self.assertIn(new_order.order_id, route.stops[2].orders.keys()) 259 | self.assertEqual(route.stops[0].type, StopType.PICK_UP) 260 | self.assertEqual(route.stops[1].type, StopType.DROP_OFF) 261 | self.assertEqual(route.stops[2].type, StopType.DROP_OFF) 262 | self.assertTrue(route.time) 263 | -------------------------------------------------------------------------------- /tests/functional/policies/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sebastian-quintero/mdrp-sim/94656681006f7bbc32b2f1970e288944c52d308a/tests/functional/policies/__init__.py -------------------------------------------------------------------------------- /tests/functional/policies/tests_greedy_matching_policy.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from datetime import time 3 | from unittest.mock import patch 4 | 5 | import numpy as np 6 | from haversine import haversine 7 | 8 | from actors.courier import Courier 9 | from objects.location import Location 10 | from objects.order import Order 11 | from objects.route import Route 12 | from objects.vehicle import Vehicle 13 | from policies.dispatcher.matching.greedy import GreedyMatchingPolicy 14 | from tests.test_utils import mocked_get_route 15 | 16 | 17 | class TestsGreedyMatchingPolicy(unittest.TestCase): 18 | """Tests for the greedy matching policy class""" 19 | 20 | @patch('settings.settings.DISPATCHER_PROSPECTS_MAX_DISTANCE', 5) 21 | def test_get_prospects(self): 22 | """Test to verify how prospects are obtained""" 23 | 24 | # Constants 25 | on_time = time(14, 0, 0) 26 | off_time = time(16, 0, 0) 27 | 28 | # Services 29 | policy = GreedyMatchingPolicy() 30 | 31 | # Case 1: verify an order is prospect to a courier 32 | order = Order(pick_up_at=Location(4.678622, -74.055694), drop_off_at=Location(4.690207, -74.044235)) 33 | courier = Courier(location=Location(4.709022, -74.035102), on_time=on_time, off_time=off_time) 34 | prospects = policy._get_prospects(orders=[order], couriers=[courier]) 35 | self.assertEqual(prospects.tolist(), [[0, 0]]) 36 | 37 | # Case 2: verify an order is not prospect to a courier 38 | order = Order(pick_up_at=Location(4.678622, -74.055694), drop_off_at=Location(4.690207, -74.044235)) 39 | courier = Courier( 40 | location=Location(4.8090, -74.9351), 41 | on_time=on_time, 42 | off_time=off_time, 43 | active_route=Route( 44 | stops=[], 45 | orders={2: Order(), 3: Order(), 4: Order()} 46 | ) 47 | ) 48 | prospects = policy._get_prospects(orders=[order], couriers=[courier]) 49 | self.assertEqual(prospects.tolist(), []) 50 | 51 | # Case 3: assert some orders being prospect and some not to some couriers 52 | order_1 = Order( 53 | pick_up_at=Location(4.678622, -74.055694), 54 | drop_off_at=Location(4.690207, -74.044235), 55 | order_id=1 56 | ) 57 | order_2 = Order( 58 | pick_up_at=Location(1.178, -72.25), 59 | drop_off_at=Location(1.690207, -75.044235), 60 | order_id=2 61 | ) 62 | courier_1 = Courier(location=Location(4.709022, -74.035102), on_time=on_time, off_time=off_time, courier_id=1) 63 | courier_2 = Courier( 64 | location=Location(4.709022, -74.035102), 65 | on_time=on_time, 66 | off_time=off_time, 67 | courier_id=2 68 | ) 69 | prospects = policy._get_prospects(orders=[order_1, order_2], couriers=[courier_1, courier_2]) 70 | self.assertEqual(len(prospects), 2) 71 | 72 | @patch('services.osrm_service.OSRMService.get_route', side_effect=mocked_get_route) 73 | def test_get_estimations(self, osrm): 74 | """Test to verify that estimations are correctly calculated""" 75 | 76 | # Constants 77 | on_time = time(15, 0, 0) 78 | off_time = time(16, 0, 0) 79 | 80 | # Services 81 | policy = GreedyMatchingPolicy() 82 | 83 | # Create an order and a courier and have them be prospects 84 | order = Order(pick_up_at=Location(4.678622, -74.055694), drop_off_at=Location(4.690207, -74.044235)) 85 | courier = Courier( 86 | location=Location(4.709022, -74.035102), 87 | on_time=on_time, 88 | off_time=off_time, 89 | vehicle=Vehicle.CAR 90 | ) 91 | prospects = np.array([[0, 0]]) 92 | 93 | # Obtain estimations and assert they are correctly calculated 94 | estimations = policy._get_estimations(orders=[order], couriers=[courier], prospects=prospects) 95 | self.assertEqual( 96 | round(estimations['distance'].tolist()[0], 2), 97 | round( 98 | haversine(courier.location.coordinates, order.pick_up_at.coordinates) + 99 | haversine(order.pick_up_at.coordinates, order.drop_off_at.coordinates), 100 | 2 101 | ) 102 | ) 103 | average_velocity = courier.vehicle.average_velocity 104 | self.assertEqual( 105 | int(estimations['time'][0]), 106 | int( 107 | haversine(courier.location.coordinates, order.pick_up_at.coordinates) / average_velocity + 108 | haversine(order.pick_up_at.coordinates, order.drop_off_at.coordinates) / average_velocity + 109 | order.pick_up_service_time + 110 | order.drop_off_service_time 111 | ) 112 | ) 113 | 114 | @patch('settings.settings.DISPATCHER_PROSPECTS_MAX_DISTANCE', 8) 115 | @patch('services.osrm_service.OSRMService.get_route', side_effect=mocked_get_route) 116 | def test_execute(self, osrm): 117 | """Test the full functionality of the greedy matching policy""" 118 | 119 | # Constants 120 | on_time = time(7, 0, 0) 121 | off_time = time(9, 0, 0) 122 | 123 | # Services 124 | policy = GreedyMatchingPolicy() 125 | 126 | # Test 1: creates an order and two couriers. 127 | # Since the courier_2 is at the same location as the pick up, asserts it should get chosen for notification 128 | order = Order(pick_up_at=Location(4.678622, -74.055694), drop_off_at=Location(4.690207, -74.044235)) 129 | courier_1 = Courier( 130 | location=Location(4.709022, -74.035102), 131 | on_time=on_time, 132 | off_time=off_time, 133 | vehicle=Vehicle.CAR, 134 | condition='idle' 135 | ) 136 | courier_2 = Courier( 137 | location=Location(4.678622, -74.055694), 138 | on_time=on_time, 139 | off_time=off_time, 140 | vehicle=Vehicle.CAR, 141 | condition='idle' 142 | ) 143 | notifications, _ = policy.execute(orders=[order], couriers=[courier_1, courier_2], env_time=3) 144 | self.assertEqual(len(notifications), 1) 145 | self.assertEqual(notifications[0].courier, courier_2) 146 | self.assertIn(order, notifications[0].instruction.orders.values()) 147 | 148 | # Test 2: creates two orders and two couriers. 149 | # The courier_1 is at the same location as the pick up of order_1. 150 | # The courier_2 is at the same location as the pick up of order_2. 151 | # In this fashion, courier_1 should be selected for order_1 and courier_2 for order_2 152 | order_1 = Order( 153 | pick_up_at=Location(4.678622, -74.055694), 154 | drop_off_at=Location(4.690207, -74.044235), 155 | order_id=1 156 | ) 157 | order_2 = Order( 158 | pick_up_at=Location(4.690207, -74.044235), 159 | drop_off_at=Location(4.678622, -74.055694), 160 | order_id=2 161 | ) 162 | courier_1 = Courier( 163 | location=Location(4.678622, -74.055694), 164 | on_time=on_time, 165 | off_time=off_time, 166 | vehicle=Vehicle.CAR, 167 | condition='idle', 168 | courier_id=1 169 | ) 170 | courier_2 = Courier( 171 | location=Location(4.690207, -74.044235), 172 | on_time=on_time, 173 | off_time=off_time, 174 | vehicle=Vehicle.CAR, 175 | condition='idle', 176 | courier_id=2 177 | ) 178 | notifications, _ = policy.execute(orders=[order_1, order_2], couriers=[courier_1, courier_2], env_time=4) 179 | self.assertEqual(len(notifications), 2) 180 | self.assertEqual(notifications[0].courier, courier_1) 181 | self.assertIn(order_1, notifications[0].instruction.orders.values()) 182 | self.assertEqual(notifications[1].courier, courier_2) 183 | self.assertIn(order_2, notifications[1].instruction.orders.values()) 184 | 185 | # Test 3: creates more orders than couriers to check nothing breaks 186 | order_1 = Order( 187 | pick_up_at=Location(4.678622, -74.055694), 188 | drop_off_at=Location(4.690207, -74.044235), 189 | order_id=1 190 | ) 191 | order_2 = Order( 192 | pick_up_at=Location(4.690207, -74.044235), 193 | drop_off_at=Location(4.678622, -74.055694), 194 | order_id=2 195 | ) 196 | courier = Courier( 197 | location=Location(4.678622, -74.055694), 198 | on_time=on_time, 199 | off_time=off_time, 200 | vehicle=Vehicle.CAR, 201 | condition='idle', 202 | courier_id=1 203 | ) 204 | notifications, _ = policy.execute(orders=[order_1, order_2], couriers=[courier], env_time=5) 205 | self.assertEqual(len(notifications), 1) 206 | self.assertEqual(notifications[0].courier, courier) 207 | self.assertIn(order_1, notifications[0].instruction.orders.values()) 208 | -------------------------------------------------------------------------------- /tests/functional/services/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sebastian-quintero/mdrp-sim/94656681006f7bbc32b2f1970e288944c52d308a/tests/functional/services/__init__.py -------------------------------------------------------------------------------- /tests/functional/services/tests_osrm_service.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from unittest.mock import patch 3 | 4 | from objects.location import Location 5 | from objects.route import Route 6 | from objects.stop import Stop 7 | from objects.vehicle import Vehicle 8 | from services.osrm_service import OSRMService 9 | from tests.test_utils import mocked_get_route 10 | 11 | 12 | class TestsOSRMService(unittest.TestCase): 13 | """Tests for the OSRM service class""" 14 | 15 | @patch('services.osrm_service.OSRMService.get_route', side_effect=mocked_get_route) 16 | def test_get_route(self, osrm): 17 | """Test to verify the route construction works correctly""" 18 | 19 | # Defines an origin and a destination 20 | origin = Location(4.678622, -74.055694) 21 | destination = Location(4.690207, -74.044235) 22 | 23 | # Obtains the route and asserts it is equal to the mocked value 24 | route = OSRMService.get_route(origin, destination) 25 | self.assertEqual( 26 | route.stops, 27 | Route( 28 | stops=[ 29 | Stop(position=0, location=origin), 30 | Stop(position=1, location=destination) 31 | ] 32 | ).stops 33 | ) 34 | 35 | @patch('services.osrm_service.OSRMService.get_route', side_effect=mocked_get_route) 36 | def test_estimate_route_properties(self, osrm): 37 | """Test to verify the route estimation works correctly""" 38 | 39 | # Defines an origin and a route that must be fulfilled 40 | origin = Location(4.678622, -74.055694) 41 | route = Route( 42 | stops=[ 43 | Stop(position=0, location=Location(4.690207, -74.044235)), 44 | Stop(position=1, location=Location(4.709022, -74.035102)) 45 | ] 46 | ) 47 | 48 | # Obtains the route's distance and time and asserts expected values 49 | distance, time = OSRMService.estimate_route_properties(origin=origin, route=route, vehicle=Vehicle.CAR) 50 | self.assertEqual(int(distance), 4) 51 | self.assertEqual(time, 594) 52 | -------------------------------------------------------------------------------- /tests/test_suite.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | if __name__ == '__main__': 4 | test_suite = unittest.TestLoader().discover('functional') 5 | unittest.TextTestRunner().run(test_suite) 6 | -------------------------------------------------------------------------------- /tests/test_utils.py: -------------------------------------------------------------------------------- 1 | from typing import Iterable, List, Tuple 2 | 3 | from actors.courier import Courier 4 | from objects.location import Location 5 | from objects.matching_metric import MatchingMetric 6 | from objects.notification import Notification 7 | from objects.order import Order 8 | from objects.route import Route 9 | from objects.stop import Stop 10 | from policies.dispatcher.matching.dispatcher_matching_policy import DispatcherMatchingPolicy 11 | 12 | 13 | class DummyMatchingPolicy(DispatcherMatchingPolicy): 14 | """Class to produce dummy notifications for testing purposes""" 15 | 16 | def execute( 17 | self, 18 | orders: Iterable[Order], 19 | couriers: Iterable[Courier], 20 | env_time: int 21 | ) -> Tuple[List[Notification], MatchingMetric]: 22 | """Implementation of the dummy policy""" 23 | 24 | return [], None 25 | 26 | 27 | def mocked_get_route(origin: Location, destination: Location) -> Route: 28 | """Method that mocks how a route is obtained going from an origin to a destination""" 29 | 30 | return Route(stops=[Stop(location=origin, position=0), Stop(location=destination, position=1)]) 31 | -------------------------------------------------------------------------------- /utils/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sebastian-quintero/mdrp-sim/94656681006f7bbc32b2f1970e288944c52d308a/utils/__init__.py -------------------------------------------------------------------------------- /utils/datetime_utils.py: -------------------------------------------------------------------------------- 1 | import math 2 | from datetime import time, datetime, date, timedelta 3 | from typing import Union 4 | 5 | 6 | def min_to_sec(minutes: float) -> Union[float, int]: 7 | """Convert minutes to seconds""" 8 | 9 | return minutes * 60 10 | 11 | 12 | def hour_to_sec(hours: float) -> Union[float, int]: 13 | """Convert hours to seconds""" 14 | 15 | return hours * 3600 16 | 17 | 18 | MAX_SECONDS = hour_to_sec(23) + min_to_sec(59) + 59 19 | 20 | 21 | def sec_to_hour(seconds: float) -> float: 22 | """Convert seconds to hours""" 23 | 24 | return seconds / 3600 25 | 26 | 27 | def sec_to_time(seconds: int) -> time: 28 | """Convert seconds since the day started to a time object""" 29 | 30 | def next_precision_frac(interval: float) -> float: 31 | """Convert time interval to fractional next precision""" 32 | 33 | return round(math.modf(interval)[0] * 60, 4) 34 | 35 | def next_precision(interval: float) -> int: 36 | """Convert fractional time interval to next precision""" 37 | 38 | return min(round(next_precision_frac(interval)), 59) 39 | 40 | mod_seconds = MAX_SECONDS if seconds >= MAX_SECONDS else seconds 41 | raw_hours = mod_seconds / 3600 42 | 43 | return time( 44 | hour=math.floor(raw_hours), 45 | minute=next_precision(raw_hours), 46 | second=next_precision(next_precision_frac(raw_hours)) 47 | ) 48 | 49 | 50 | def time_to_sec(raw_time: time) -> Union[float, int]: 51 | """Convert time object to seconds""" 52 | 53 | return hour_to_sec(raw_time.hour) + min_to_sec(raw_time.minute) + raw_time.second 54 | 55 | 56 | def time_to_query_format(query_time: time) -> str: 57 | """Parse a time object to a str available to use in a query""" 58 | 59 | return f'\'{query_time.hour}:{query_time.minute}:{query_time.second}\'' 60 | 61 | 62 | def time_diff(time_1: time, time_2: time) -> float: 63 | """Returns the difference in seconds of time_1 - time_2""" 64 | 65 | diff = datetime.combine(date.today(), time_1) - datetime.combine(date.today(), time_2) 66 | 67 | return diff.total_seconds() 68 | 69 | 70 | def time_add(time_to_add: time, seconds: float) -> time: 71 | """Adds the desired seconds to the time provided""" 72 | 73 | return (datetime.combine(date.today(), time_to_add) + timedelta(seconds=seconds)).time() 74 | 75 | 76 | def time_to_str(time_to_convert: time) -> str: 77 | """Converts a time object to str""" 78 | 79 | return time_to_convert.strftime('%H:%M:%S') 80 | -------------------------------------------------------------------------------- /utils/diagrams/courier.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sebastian-quintero/mdrp-sim/94656681006f7bbc32b2f1970e288944c52d308a/utils/diagrams/courier.png -------------------------------------------------------------------------------- /utils/diagrams/dispatcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sebastian-quintero/mdrp-sim/94656681006f7bbc32b2f1970e288944c52d308a/utils/diagrams/dispatcher.png -------------------------------------------------------------------------------- /utils/diagrams/user.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sebastian-quintero/mdrp-sim/94656681006f7bbc32b2f1970e288944c52d308a/utils/diagrams/user.png -------------------------------------------------------------------------------- /utils/logging_utils.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import sys 3 | 4 | from simpy import Environment 5 | 6 | from settings import settings 7 | from utils.datetime_utils import sec_to_time 8 | 9 | LOG_PATTERN = '[%(asctime)s][%(levelname)s] | %(message)s ' 10 | LOG_DATE_PATTERN = '%Y-%m-%d %H:%M:%S' 11 | 12 | 13 | def configure_logs(): 14 | """Method to configure the structure of a log""" 15 | 16 | logging.basicConfig( 17 | format=LOG_PATTERN, 18 | level=logging.INFO, 19 | datefmt=LOG_DATE_PATTERN, 20 | stream=sys.stdout 21 | ) 22 | 23 | 24 | def log(env: Environment, actor_name: str, condition: str, msg: str): 25 | """Method to handle how an info log is shown""" 26 | 27 | if settings.VERBOSE_LOGS: 28 | logging.info(f'sim time = {sec_to_time(env.now)} | actor = {actor_name} (condition = {condition}) | {msg}') 29 | 30 | 31 | def world_log(dispatcher) -> str: 32 | """Method to log the state of the world""" 33 | 34 | return f'| Couriers => ' \ 35 | f'{len(dispatcher.idle_couriers)} idle, ' \ 36 | f'{len(dispatcher.moving_couriers)} moving, ' \ 37 | f'{len(dispatcher.picking_up_couriers)} picking_up, ' \ 38 | f'{len(dispatcher.dropping_off_couriers)} dropping_off, ' \ 39 | f'{len(dispatcher.logged_off_couriers)} logged_off. ' \ 40 | f'| Orders => ' \ 41 | f'{len(dispatcher.placed_orders)} placed, ' \ 42 | f'{len(dispatcher.unassigned_orders)} unassigned, ' \ 43 | f'{len(dispatcher.assigned_orders)} assigned, ' \ 44 | f'{len(dispatcher.fulfilled_orders)} fulfilled, ' \ 45 | f'{len(dispatcher.canceled_orders)} canceled. ' 46 | --------------------------------------------------------------------------------