Why do buses come in threes?

Rob Eastway has a book titled “Why do buses come in threes?” which is a collection of musings on the some of the mathematics behind some simple, and often common questions. The question that he used for the title of the book intrigued me, as his answer was (basically) that they don’t. They do come in twos (which he explained), but would probably not come in threes.

I wondered how hard it would be to write a simulation to test this out. The simulation I describe here isn’t bad, but has (at least) three obvious shortcomings, which I didn’t really spot until the end. Although I’ve thought these through, I haven’t had time to try them yet, so this blog post will describe the first pass at the simulation (which is still quite interesting to watch).

This was all done in LinqPad, although there’s no reason why you can’t use VS if you prefer.

The models

Clearly, the two basic models we need for this are a bus and a bus stop. I spent a while wondering how best to connect the two together, and after coming up with some complex object graphs, decided that it would just be easier to keep a collection of each, and have each bus know how far it had gone along the route.

For simplicity, I used two records…

record Bus(int Number, int Distance = 0);
record BusStop(int Distance, int PassengersWaiting = 0);

I could then build a route as a collection of bus stops, each of which was a certain distance down the route…

List<BusStop> busStops = new() { new(10), new(20), new(30), new(40), new(45),
  new(55), new(70), new(82), new(87), new(95), new(110) };

I’m deliberately ignoring actual units here, so you’ll have to think in terms of units of distance along the route and units of time for the simulation.

Displaying the simulation

In order to see what was going on, I needed a way to display the route. As the route didn’t change once it had been set up, I stored this as a string to be reused every time we refreshed the animation…

string stopsDisplay = string.Join("", Enumerable.Range(0, maxDistance)
  .Select(n => busStops.Any(bs => bs.Distance == n) ? "|" : "-")) + "|";

For the route shown just above, this gave the following display…

----------|---------|---------|---------|----|---------|--------------|-----------|----|-------|--------------|

Won’t win any prizes for aesthetic value, but it does the job!

The simulation

So what we need to do is set off buses at regular time intervals, and have them pick up passengers as they arrive at the stops. A few variables were in order to configure the simulation…

int timeBetweenBuses = 10;
// The maximum number of passengers that can arrive at a bus stop at any one time
int maxNewPassengers = 5;
// The number of passengers that can board the bus in one time interval.
// If the number of passengers waiting exceeds this, the bus will be at the stop for longer
int boardingRate = 5;

The main part of the simulation was a simple loop that kept going until the first bus reached the end of the route.

int time = 0;
do {
  // Is it time for the next bus to start?
  if (time % timeBetweenBuses == 0) {
    int newNumber = buses.Any() ? buses.Max(b => b.Number) + 1 : 1;
    buses.Add(new(newNumber));
  }
  // Have passengers arrive at three randomly chosen stops
  for (int i = 0; i < r.Next() % 3; i++) {
    // get the index of the bus stop, so we can add the passengers
    int n = r.Next() % busStops.Count;
    busStops[n] = busStops[n] with { PassengersWaiting = busStops[n].PassengersWaiting + r.Next() % maxNewPassengers };
  }
  Util.ClearResults();
  // See what each bus should do in this time interval
  for (int n = 0; n < buses.Count; n++) {
    Bus bus = buses[n];
    if (busStops.Any(l => l.Distance == bus.Distance)) {
      // We are at a bus stop. Get the index of the stop, so we can update in place (if we take on passengers)
      int stopIndex = busStops.Select((bs, n) => (bs, n)).Where(t => t.bs.Distance == bus.Distance).Select(t => t.n).First();
      if (busStops[stopIndex].PassengersWaiting > 0) {
        // There are passengers, so take on the number that can board in one time unit and don't move on
        busStops[stopIndex] = busStops[stopIndex] 
          with { PassengersWaiting = Math.Max(0, busStops[stopIndex].PassengersWaiting - boardingRate) };
      } else {
        // There aren't any passengers, just move on if the next location is empty.
        if (!buses.Any(b => b.Number != bus.Number && b.Distance == bus.Distance + 1)) {
          buses[n] = bus with { Distance = bus.Distance + 1 };
        }
      }
    } else {
      // We are not at a bus stop. If the next location is empty, move on, otherwise stay here
      if (!buses.Any(b => b.Number != bus.Number && b.Distance == bus.Distance + 1)) {
        buses[n] = bus with { Distance = bus.Distance + 1 };
      }
    }
  }
  $"Time: {time}".DumpFixed();
  ShowPassengers().DumpFixed();
  stopsDisplay.DumpFixed();
  ShowBuses().DumpFixed();
  await Task.Delay(delay);
  time++;
} while (buses.First().Distance < maxDistance);

Hopefully this is all clearly commented enough to make sense. The code isn’t the best it could be, mainly because this was a quick play, not a serious project. However, it’s good enough to do the job.

Two small technical points

1) The animation is done simply by clearing the console and writing out the display for the current time period. The obvious way to do this wold be to call Console.Clear(), but this gives an IOException: _the handle is invalid._ Can’t remember the reason offhand, but LinqPad has a util function to do the same thing without the exception, so I call Util.ClearResults() at the start of each loop.

2) In order for the display to line up correctly, we need to use a fixed-width font. By default, LinqPad uses a variable-width font for the output panel, which doesn’t work well here. One way to get around this would be to change the font used for the panel. I asked about this five years ago, and the answer there is still valid. However, I don’t want to change the font permanently, I just want to do so for this simulation. Joe Albahari was kind enough to post a useful function that allows you to do this on a case-by-case basis. That explains why the output in the code above uses DumpFixed() instead of Console.WriteLine. See the link for the actual code for DumpFixed().

You can see the full code for this simulation here. Copy the code and paste it into LinqPad, having set the language to “C# Statements.”

Here is a sample run…

If you don’t see anything after a few seconds, try refreshing the page and scrolling back here. Could be the animation ended before you read this far.

Not bad for about 90 minutes fun. However, watching the simulation made me realise that I had made some basic mistakes.

Room for improvement

Three issues immediately became apparent…

1) Passengers arrive at all stops right from the start, meaning that by the time the first bus gets to a later stop, there is a huge queue. You can see this in the simulation, which causes bus #1 to keep stopping, resulting in subsequent buses catching it up. You end up with a big cluster of buses at the end.

It would be better if we have passengers arrive at any stops stops up to (say) five time units ahead of the current time. That’s more realistic, as people don’t generally turn up at a bus stop ages in advance. They normally aim to get there in enough time to make sure they don’t miss the bus.

2) The buses have an infinite capacity (bit like Hilbert’s Hotel on wheels!), so the first bus gets all the passengers. We should give the buses a maximum capacity, so if a bus is full, it carries on and leaves the next bus to pick up the passengers. This will keep us out of trouble with the health and safety people as well!

3) No-one ever gets off the buses, so even if we added a capacity, once a bus were full, it would never stop again. We should change it so that a random number of people get off the bus at each stop.

None of these issues are major, but I haven’t had time to tackle them yet. Watch out for a follow-up – but don’t hold your breath!

Be First to Comment

Leave a Reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.