53  🚗 EV Charging Bays Simulation

Have you ever wondered how many fast-charging bays an electric vehicle (EV) charging station should have to meet the demand of EV drivers? In this section, we will simulate an EV fast-charging station to determine the optimal number of charging bays needed to minimise wait times and maximise customer satisfaction.

Scenario description

Curtin University installs an EV fast‑charging station at yellow car park PI1 next to the John Curtin Gallery (Building 200A). The station has:

  • 2 identical DC fast chargers (each up to 75 kW)
  • A single queue of EVs; the next free charger takes the next car
  • Operating hours: 8:00–20:00 (12 hours per day)

The university wants to know:

  • How often do EVs have to wait for a charger?
  • What is the average waiting time?
  • Are two chargers enough, or should they plan for a third?

Mapping to an M/M/2 model

We model the station as an M/M/2 queue with the following assumptions:

  • Arrivals (M):
    EVs arrive randomly, independently → Poisson process with rate \(\lambda\) (cars/hour).

  • Service times (M):
    Charging times are variable (battery level, charger power, user behaviour).
    We approximate them as exponential with mean \(1/\mu\) hours.

  • Servers (2):
    Two identical chargers → \(c = 2\).

  • Queue discipline:
    First‑come, first‑served, single queue.

Suppose the university collects data over several days:

  • Over 10 days, 12 hours/day → 120 hours of observation
  • They observe 360 EV arrivals
    \[ \hat{\lambda} = \frac{360}{120} = 3 \text{ cars/hour} \]
  • From charger logs, the average charging session is 30 minutes (0.5 hours)
    \[ \hat{\mu} = \frac{1}{0.5} = 2 \text{ cars/hour per charger} \]

We’ll use:

  • \(\lambda = 3\) cars/hour
  • \(\mu = 2\) cars/hour
  • \(c = 2\) chargers

Simulating the queue

The following function simulate the M/M/c queue for a given parameter \(\lambda, \mu, c\) and simulation time.

simulate_mmc <- function(lambda, mu, c, sim_time, seed = 1234) {
  if (!is.null(seed)) set.seed(seed)

  # System state
  T <- 0
  X <- 0

  # Output dataframe
  df <- data.frame(
    time   = 0,
    state  = 0,
    event  = "Start",
    bay = NA,
    start  = NA,
    end    = NA
  )

  # Next arrival
  T_a <- rexp(1, lambda)

  # Active services: one row per busy bay
  services <- data.frame(
    bay = integer(0),
    start  = numeric(0),
    end    = numeric(0)
  )

  while (T < sim_time) {

    T_s <- if (nrow(services) > 0) min(services$end) else Inf

    if (min(T_a, T_s) > sim_time) break

    if (T_a < T_s) {
      # Arrival
      T <- T_a
      X <- X + 1

      df <- rbind(
        df,
        data.frame(
          time = T, state = X, event = "Arrival",
          bay = NA, start = NA, end = NA
        )
      )

      T_a <- T + rexp(1, lambda)

      # Start service immediately if a bay is free
      if (X <= c) {
        busy  <- services$bay
        idle  <- setdiff(seq_len(c), busy)
        srv   <- sample(idle, 1) # assign to a random free bay
        dur   <- rexp(1, mu)

        services <- rbind(
          services,
          data.frame(
            bay = srv,
            start  = T,
            end    = T + dur
          )
        )
      }

    } else {
      # Departure 
      idx <- which.min(services$end)
      srv <- services$bay[idx]
      s0  <- services$start[idx]
      s1  <- services$end[idx]

      T <- s1
      X <- X - 1

      df <- rbind(
        df,
        data.frame(
          time = T, state = X, event = "Departure",
          bay = srv, start = s0, end = s1
        )
      )

      # Remove completed service
      services <- services[-idx, ]

      # If queue non‑empty, same bay starts next service immediately
      if (X >= c) {
        dur <- rexp(1, mu)

        services <- rbind(
          services,
          data.frame(
            bay = srv,
            start  = T,
            end    = T + dur
          )
        )
      }
    }
  }

  rownames(df) <- NULL
  df
}

To simulate one-day operation (12 hours) and plot a sample path, run:

df <- simulate_mmc(lambda = 3, mu = 2, c = 2, sim_time = 12, seed = 1234)
df
         time state     event bay      start        end
1   0.0000000     0     Start  NA         NA         NA
2   0.8339195     1   Arrival  NA         NA         NA
3   0.9161725     2   Arrival  NA         NA         NA
4   0.9742301     1 Departure   1  0.8339195  0.9742301
5   1.0822563     0 Departure   1  0.9161725  1.0822563
6   2.3742858     1   Arrival  NA         NA         NA
7   2.3837866     2   Arrival  NA         NA         NA
8   2.4192606     1 Departure   2  2.3742858  2.4192606
9   2.6584804     2   Arrival  NA         NA         NA
10  2.8149918     1 Departure   1  2.3837866  2.8149918
11  2.8833441     2   Arrival  NA         NA         NA
12  3.0386955     1 Departure   2  2.6584804  3.0386955
13  3.3631426     0 Departure   1  2.8833441  3.3631426
14  3.5100364     1   Arrival  NA         NA         NA
15  3.8113433     2   Arrival  NA         NA         NA
16  4.1426883     1 Departure   1  3.8113433  4.1426883
17  4.3949033     2   Arrival  NA         NA         NA
18  4.7718502     1 Departure   1  4.3949033  4.7718502
19  5.0362654     0 Departure   2  3.5100364  5.0362654
20  5.4739824     1   Arrival  NA         NA         NA
21  5.7779277     2   Arrival  NA         NA         NA
22  5.9510414     3   Arrival  NA         NA         NA
23  6.0788886     4   Arrival  NA         NA         NA
24  6.1421209     3 Departure   1  5.7779277  6.1421209
25  6.2639401     2 Departure   1  6.1421209  6.2639401
26  6.3915144     1 Departure   1  5.4739824  6.3915144
27  6.3923701     2   Arrival  NA         NA         NA
28  6.3937018     3   Arrival  NA         NA         NA
29  6.7043330     4   Arrival  NA         NA         NA
30  6.7645139     3 Departure   1  6.3923701  6.7645139
31  6.7690598     2 Departure   1  6.7645139  6.7690598
32  6.7700541     1 Departure   1  6.2639401  6.7700541
33  6.8491808     2   Arrival  NA         NA         NA
34  6.8518031     3   Arrival  NA         NA         NA
35  7.1003810     2 Departure   1  6.8491808  7.1003810
36  7.1148876     1 Departure   1  7.1003810  7.1148876
37  7.4463008     2   Arrival  NA         NA         NA
38  7.5742028     1 Departure   1  6.7690598  7.5742028
39  7.7669260     2   Arrival  NA         NA         NA
40  7.7726244     3   Arrival  NA         NA         NA
41  7.8314959     2 Departure   1  7.7669260  7.8314959
42  8.2462255     1 Departure   2  7.4463008  8.2462255
43  8.4691512     2   Arrival  NA         NA         NA
44  8.4819445     3   Arrival  NA         NA         NA
45  8.8071861     4   Arrival  NA         NA         NA
46  8.9195164     5   Arrival  NA         NA         NA
47  8.9524676     6   Arrival  NA         NA         NA
48  8.9819807     7   Arrival  NA         NA         NA
49  9.0909073     6 Departure   1  7.8314959  9.0909073
50  9.1986469     5 Departure   1  9.0909073  9.1986469
51  9.5692920     4 Departure   2  8.4691512  9.5692920
52  9.6269596     3 Departure   2  9.5692920  9.6269596
53  9.6338779     4   Arrival  NA         NA         NA
54 10.0319087     3 Departure   1  9.1986469 10.0319087
55 10.1136137     4   Arrival  NA         NA         NA
56 10.1213361     5   Arrival  NA         NA         NA
57 10.3564196     4 Departure   2  9.6269596 10.3564196
58 10.4193172     5   Arrival  NA         NA         NA
59 11.3883852     6   Arrival  NA         NA         NA
60 11.4139295     5 Departure   1 10.0319087 11.4139295
61 11.4317693     6   Arrival  NA         NA         NA
62 11.8210187     5 Departure   1 11.4139295 11.8210187
plot(
  df$time, df$state,
  type = "s",
  xlab = "Time (hours)",
  ylab = "Number of EVs in the system",
  main = "M/M/2 sample path"
)

points(df$time, df$state, pch = 16,
       col = ifelse(is.na(df$bay), "black", ifelse(df$bay == 1, "blue", "red")))

legend(
  "topleft", bty = "n", pch = 16,
  legend = c("Bay 1", "Bay 2", "Start/Arrival"),
  col = c("blue", "red", "black"))

# simulate for 250 working days in a year
df <- simulate_mmc(lambda = 3, mu = 2, c = 2, sim_time = 3000, seed = 1234)

lambda <- 3
mu     <- 2
c      <- 2

# durations spent in each state
durations <- diff(df$time) # time spent in each interval
states <- df$state[-nrow(df)]   # state held over each interval

# Empirical performance metrics
rho_hat <- sum((states > 0) * durations) / sum(durations)
L_hat <- sum(states * durations) / sum(durations)
Lq_hat <- L_hat - rho_hat
W_hat <- L_hat / lambda
Wq_hat <- W_hat - 1 / mu
P0_hat <- sum((states == 0) * durations) / sum(durations)

# Theoretical performance metrics

# Erlang-C formula for M/M/c probability of waiting
erlang_c <- function(lambda, mu, c) {
  a   <- lambda / mu
  rho <- lambda / (c * mu)

  # denominator: sum_{k=0}^{c-1} a^k/k!
  sum_terms <- sum(sapply(0:(c-1), function(k) a^k / factorial(k)))

  # numerator term: a^c / (c! * (1 - rho))
  top <- (a^c / factorial(c)) * (1 / (1 - rho))

  # Erlang C probability
  P_wait <- top / (sum_terms + top)
  return(P_wait)
}

P_wait <- erlang_c(lambda, mu, c); P_wait
[1] 0.6428571
rho <- lambda / (c*mu)
Lq_theory <- P_wait * (rho / (1 - rho))
L_theory <- Lq_theory + (lambda / mu)
Wq_theory <- Lq_theory / lambda
W_theory <- Wq_theory + (1 / mu)

P0_mmc <- function(lambda, mu, c) {
  a   <- lambda / mu
  rho <- lambda / (c * mu)

  sum_terms <- sum(sapply(0:(c-1), function(k) a^k / factorial(k)))
  top       <- (a^c / factorial(c)) * (1 / (1 - rho))

  P0 <- 1 / (sum_terms + top)
  return(P0)
}

P0_theory <- P0_mmc(lambda, mu, c)


data.frame(
   Metric = c("Utilisation", "Avg. number in system", "Avg. number in queue", 
              "Avg. response time", "Avg. waiting time", "System empty probability"),
   Simulated = c(rho_hat, L_hat, Lq_hat, W_hat, Wq_hat, P0_hat),
   Theoretical = c(rho, L_theory, Lq_theory, W_theory, Wq_theory, P0_theory)
)
                    Metric Simulated Theoretical
1              Utilisation 0.8386470   0.7500000
2    Avg. number in system 3.2362600   3.4285714
3     Avg. number in queue 2.3976131   1.9285714
4       Avg. response time 1.0787533   1.1428571
5        Avg. waiting time 0.5787533   0.6428571
6 System empty probability 0.1613530   0.1428571

From the table, we can see that the simulated performance metrics are close to the theoretical benchmarks, which validates our simulation model.

  • The system is stable and reasonably well-utilised with \(\rho < 1\).
  • Theoretically, the utilisation per charger is around 75% of the time, which suggests that each charger is busy about 75% of the time. The difference between the simulated and theoretical utilisation can be attributed to the randomness in the simulation and the finite simulation time, but reasonable for one trajectory.
  • The average number of EVs in the system is between 3 to 4 cars.
  • The average number of EVs waiting is around 1 to 2 cars. The simulated value is slightly higher, which is consistent with higher utilisation.
  • The average response time (time in system) is around 1 hour.
  • The average waiting time before charging is about 0.6 hours (36 minutes).
  • The probability that the station is empty (no cars) is around 15%.

Discussion

Answering the original questions:

  • How often do EVs have to wait for a charger?
    • Answer: The probability of waiting is around 64% (from Erlang C formula), which means that most EVs will have to wait for a charger during busy periods.
  • What is the average waiting time?
    • Answer:The average waiting time is around 36 minutes, which may be considered long for a fast-charging station. This could lead to customer dissatisfaction during peak hours.
  • Are two chargers enough, or should they plan for a third?
    • Answer:With two chargers, the utilisation is quite high, and the waiting times are significant. Adding a third charger would reduce the utilisation and significantly decrease waiting times, improving customer satisfaction. The decision to add a third charger would depend on the cost of installation versus the benefits of reduced waiting times and increased customer satisfaction.