\( \newcommand{\NOT}{\neg} \newcommand{\AND}{\wedge} \newcommand{\OR}{\vee} \newcommand{\XOR}{\oplus} \newcommand{\IMP}{\Rightarrow} \newcommand{\IFF}{\Leftrightarrow} \newcommand{\TRUE}{\text{True}\xspace} \newcommand{\FALSE}{\text{False}\xspace} \newcommand{\IN}{\,{\in}\,} \newcommand{\NOTIN}{\,{\notin}\,} \newcommand{\TO}{\rightarrow} \newcommand{\DIV}{\mid} \newcommand{\NDIV}{\nmid} \newcommand{\MOD}[1]{\pmod{#1}} \newcommand{\MODS}[1]{\ (\text{mod}\ #1)} \newcommand{\N}{\mathbb N} \newcommand{\Z}{\mathbb Z} \newcommand{\Q}{\mathbb Q} \newcommand{\R}{\mathbb R} \newcommand{\C}{\mathbb C} \newcommand{\cA}{\mathcal A} \newcommand{\cB}{\mathcal B} \newcommand{\cC}{\mathcal C} \newcommand{\cD}{\mathcal D} \newcommand{\cE}{\mathcal E} \newcommand{\cF}{\mathcal F} \newcommand{\cG}{\mathcal G} \newcommand{\cH}{\mathcal H} \newcommand{\cI}{\mathcal I} \newcommand{\cJ}{\mathcal J} \newcommand{\cL}{\mathcal L} \newcommand{\cK}{\mathcal K} \newcommand{\cN}{\mathcal N} \newcommand{\cO}{\mathcal O} \newcommand{\cP}{\mathcal P} \newcommand{\cQ}{\mathcal Q} \newcommand{\cS}{\mathcal S} \newcommand{\cT}{\mathcal T} \newcommand{\cV}{\mathcal V} \newcommand{\cW}{\mathcal W} \newcommand{\cZ}{\mathcal Z} \newcommand{\emp}{\emptyset} \newcommand{\bs}{\backslash} \newcommand{\floor}[1]{\left \lfloor #1 \right \rfloor} \newcommand{\ceil}[1]{\left \lceil #1 \right \rceil} \newcommand{\abs}[1]{\left | #1 \right |} \newcommand{\xspace}{} \newcommand{\proofheader}[1]{\underline{\textbf{#1}}} \)

11.5 Creating a Discrete-Event Simulation

Let’s put together all of the classes we’ve designed over the course of this chapter to create a full simulation our of food delivery system. In this section, we’ll first learn about how the main simulation loop works. Then, we’ll turn our attention to the possible ways a simulation can be configured, and how to incorporate these configuration options as part of the public interface of a class.

The main simulation loop

Before we get to creating a full simulation class, we’ll discuss how our simulation works. The type of simulation we’re learning about is called a discrete-event simulation, because it is driven by individual events occurring at specified periods of time.

A discrete-event simulation runs as follows:

  1. It keeps track of a collection of events, which begins with some initial events. The collection is a priority queue, where an event’s priority is its timestamp (earlier timestamps mean higher priority).
  2. The highest-priority event (i.e., the one with the earliest timestamp) is removed and processed. Any new events it generates are added to the priority queue.
  3. Step 2 repeats until there are no events left.

The algorithm is remarkably simple, though it does rely on a slightly modified version of our priority queue implementation from 10.8 Priority Queues.In that section, we used ints to represent priority, while here we’re using datetime.datetime values. Assuming we have such an implementation called EventQueueList, here is how we could write a simple function that runs this simulation loop:

def run_simulation(initial_events: list[Event], system: FoodDeliverySystem) -> None:
    events = EventQueueList()  # Initialize an empty priority queue of events
    for event in initial_events:
        events.enqueue(event)

    # Repeatedly remove and process the next event
    while not events.is_empty():
        event = events.dequeue()

        new_events = event.handle_event(system)
        for new_event in new_events:
            events.enqueue(new_event)

The main reason for this implementation’s simplicity is abstraction. Remember that Event is an abstract class; the complex behaviour of how different events are handled is deferred to its concrete subclasses via our calls to event.handle_event. Our run_simulation function is polymorphic: it works regardless of what Event instances are contained in initial_events, or what new events are generated and stored in new_events. The only thing our function needs to be able to do is call the handle_event method on each event object, which we can assume is present because it is defined in the Event public interface.

A simulation class

Next, we will take our run_simulation in the previous section and “wrap” it inside a new class. This isn’t necessary to the running of the simulation, but is a standard practice in an object-oriented design, and makes it easier to both configure the simulation parameters and report results after the simulation is complete.

We’re going to begin with a sketch of a class to represent our simulation:

class FoodDeliverySimulation:
    """A simulation of the food delivery system.
    """
    # Private Instance Attributes:
    #   - _system: The FoodDeliverySystem instance that this simulation uses.
    #   - _events: A collection of the events to process during the simulation.
    _system: FoodDeliverySystem
    _events: EventQueue

    def __init__(self, start_time: datetime.datetime, num_days: int,
                 num_couriers: int, num_customers: int,
                 num_restaurants: int) -> None:
        """Initialize a new simulation with the given simulation parameters.

        start_time: the starting time of the simulation
        num_days: the number of days that the simulation runs
        num_couriers: the number of couriers in the system
        num_customers: the number of customers in the system
        num_restaurants: the number of restaurants in the system
        """
        self._events = EventQueueList()
        self._system = FoodDeliverySystem()

        self._populate_initial_events(start_time, num_days)
        self._generate_system(num_couriers, num_customers, num_restaurants)

    def _populate_initial_events(self, start_time: datetime.datetime, num_days: int) -> None:
        """Populate this simulation's Event priority queue with GenerateOrdersEvents.

        One new GenerateOrderEvent is generated per day, starting with start_time and
        repeating num_days times.
        """

    def _generate_system(self, num_couriers: int, num_customers: int, num_restaurants: int) -> None:
        """Populate this simulation's FoodDeliverySystem with the specified number of entities.
        """

    def run(self) -> None:
        """Run this simulation.
        """
        while not self._events.is_empty():
            event = self._events.dequeue()

            new_events = event.handle_event(self._system)
            for new_event in new_events:
                self._events.enqueue(new_event)

There are a few key items to note in this (incomplete) implementation:

  1. The run_simulation method has been renamed to simply run, since it’s a method in the FoodDeliverySimulation class.

  2. The local variable events and parameter system from the function are now instance attributes for the FoodDeliverySimulation class, and have been moved out of the run method entirely. It’s the job of FoodDeliverySimulation.__init__ to initialize these objects.

  3. The initializer takes in several parameters representing configuration values for the simulation. It then uses these values in two helper methods to initialize the _system and _events objects. These methods are marked private (named with a leading underscore) because they’re only meant to be called by the initializer, and not code outside of the class.

    Here is how we could use the FoodDeliverySimulation class:

    >>> simulation = FoodDeliverySimulation(datetime.datetime(2022, 11, 30), 7, 4, 100, 50)
    >>> simulation.run()

Next, we’ll briefly discuss one way to implement each of the two key helper methods for the initializer, _populate_initial_events and _generate_system.

Populating initial events

The key idea for our first helper method is that given a start time and a number of days, our initial events will be a series of GenerateOrderEvents that will generate NewOrderEvents when they are processed. Here is the basic skeleton, which will be leave as an exercise for you to complete:

    def _populate_initial_events(self, start_time: datetime.datetime, num_days: int) -> None:
        """Populate this simulation's Event priority queue with GenerateOrdersEvents.

        One new GenerateOrderEvent is generated per day, starting with start_time and
        repeating num_days times.

        Preconditions:
        - num_days >= 0
        """
        for day in range(0, num_days):
            # 1. Create a GenerateOrderEvent for the given day after the start time.

            # 2. Enqueue the new event.

Populating the system entities

The way that our simulation is currently set up, our FoodDeliverySystem instance will contain all vendors, customers, and couriers before the events start being processed. That is, we are assuming that only orders are dynamic in our system; the vendors, customers, and couriers do not change over time.

The easiest way to populate these three entity types is to randomly generate new instances of each of these classes. We’ve shown an example with Customers below.

    def _generate_system(self, num_couriers: int, num_customers: int, num_restaurants: int) -> None:
        """Populate this simulation's FoodDeliverySystem with the specified number of entities.
        """
        for i in range(0, num_customers):
            location = _generate_location()
            customer = Customer(f'Customer {i}', location)
            self._system.add_customer(customer)

        # Couriers and Restaurants are similar
        ...


# Outside the class: helper for generating random locations in Toronto
TORONTO_COORDS = (43.747743, 43.691170, -79.633951, -79.176646)


def _generate_location() -> tuple[float, float]:
    """Return a randomly-generated location (latitude, longitude) within the Toronto bounds.
    """
    return (random.uniform(TORONTO_COORDS[0], TORONTO_COORDS[1]),
            random.uniform(TORONTO_COORDS[2], TORONTO_COORDS[3]))

Putting it all together

After completing the implementation of these two helper methods, you are ready to run the simulation! Try doing the following in the Python console:

>>> simulation = FoodDeliverySimulation(datetime.datetime(2022, 11, 30), 7, 4, 100, 50)
>>> simulation.run()

Of course, we aren’t printing anything out, and the FoodDeliverySimualtion.run method doesn’t actually return anything. You are free to insert some print calls to see whether events are actually being processed, but that’s not the only way to see the results of the simulation.

Once the simulation is complete, self._system will have accumulated several completed orders, as a list[Order]. We can access these values and perform any kind of computation on them we want, using comprehensions and/or for loops like we’ve done throughout this course!

For example, we might ask: