Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Circular City (Toy model)

Estimated time to complete: 2 hours

This case study presents a toy model designed to be both simple and feature-rich:

  • Quick to run: Executes in just a few minutes.
  • Concise configuration: Requires only a single TOML file (~100 lines).
  • Comprehensive: Incorporates most of METROPOLIS2’s core features, including mode choice, departure-time choice, route choice, fuel consumption, spillback effects, and ridesharing.

The model simulates an imaginary circular city, featuring ring roads (circular roads) and radial roads (arterial roads).

This structure is inspired by the foundational work of de Palma, Kilani, and Lindsey (2005), though it is not an exact replication. Instead, it offers a modern adaptation tailored for METROPOLIS2.

Preparation

As for the Bottleneck case study, begin by creating a TOML configuration file (e.g., config-circular-city.toml) in your preferred directory. This file serves as the foundation for your simulation.

Start by defining the main_directory and random_seed parameters in your configuration file. The main_directory specifies where all simulation outputs will be saved, while the random_seed ensures your results are reproducible.

# config-circular-city.toml
main_directory = "circular-city/"

random_seed = 123454321

Supply side

The Circular City model uses the CircularNetworkStep to create a customizable road network composed of ring roads and radial roads.

The nb_radials parameter defines the number of directions for radial roads:

  • nb_radials = 4: creates radial roads in the North, East, South, and West directions;
  • nb_radials = 8: adds Northeast, Northwest, Southeast, and Southwest directions;
  • nb_radials = n: n roads are automatically generated starting from the East direction and are evenly spaced around the circle.

Parameter nb_rings controls the number of concentric rings in the city and parameter radius defines the distance between rings (in meters). If radius is a single value, for example radius = 2000, each ring is space 2 km apart from the city center. If radius is a list, for example radius = [1000, 1500] (with nb_rings = 2), the first ring is 1 km from the center and the second is 1.5 km from the center (500 m apart).

Nodes are automatically created at the intersection of each ring and radial road.

For more details, including how node and edge ids are generated, refer to the CircularNetworkStep documentation.

For the same topological network as in de Palma et al. (2005), use the following configuration. Feel free to tweak it to your liking!

[circular_network]
nb_radials = 8
nb_rings = 4
radius = 4000
Circular network with node labels

Figure 1. Circular network with node labels.

File used: edges_raw.geo.parquet

Tip

If you are more into American-style cities, consider creating a grid network instead.

To finalize the Circular City network configuration, you need to define the speed limits, number of lanes and bottleneck capacities. This is done using PostprocessRoadNetworkStep and ExogenousCapacitiesStep, similar to the Bottleneck case study.

The key parameters are:

Circular network with road types

Figure 2. Circular network with edges’ road types.

Edges with the same color share the same road type.

File used: edges_raw.geo.parquet

The values can be specified per road type (see map) by providing a TOML table with the road-type labels as keys. To replicate the values from de Palma et al. (2005), use the following configuration.

[road_network]
[road_network.default_speed_limit]
"Radial 1" = 50
"Radial 2" = 50
"Radial 3" = 50
"Radial 4" = 50
"Ring 1" = 50
"Ring 2" = 70
"Ring 3" = 70
"Ring 4" = 70

[road_network.default_nb_lanes]
"Radial 1" = 2
"Radial 2" = 2
"Radial 3" = 2
"Radial 4" = 2
"Ring 1" = 1
"Ring 2" = 1
"Ring 3" = 1
"Ring 4" = 1

[road_network.capacities]
"Radial 1" = 1500
"Radial 2" = 2000
"Radial 3" = 2000
"Radial 4" = 2000
"Ring 1" = 2000
"Ring 2" = 2000
"Ring 3" = 2000
"Ring 4" = 2000

Demand side

To simulate trips in the Circular City model, you can use an origin-destination matrix, where road-network nodes serve as origins and destinations.

In real-world scenarios, the number of trips between two nodes typically decreases with distance. The GravityODMatrixStep models this effect by assuming that the number of trips between nodes decays exponentially with distance.

The key parameters for this step are defined below.

  • trips_per_node defines the total number of trips originating from each node.
  • exponential_decay controls how quickly the number of trips decreases with distance. With exponential_decay = 0, trips originating from a node are uniformly distributed across all destinations. With higher values, more trips are concentrated toward closer destinations.
[gravity_od_matrix]
trips_per_node = 8000
exponential_decay = 0.07
Flows from NE-2 node

Figure 3. Flows originating from node “NE-2”.

Files used: road_origin_destination_matrix.geo.parquet, edges_raw.geo.parquet

Tip

If you want different number of trips originating from each node, you can define trips_per_node as a distribution. For example:

trips_per_node = { mean = 8000, std = 1000, distribution = "Normal" }

Tip

For full control over trip generation, you can create your own origin-destination matrix and use it with the CustomODMatrixStep.

To align with de Palma et al. (2005), you can configure agents to choose between car and public transit using a Logit model. This setup allows agents to make probabilistic choices based on the utility of each mode.

[mode_choice]
modes = ["car_driver", "public_transit"]
model = "Logit"
mu = 1

The parameters are defined as follow.

  • modes lists the available travel modes.
  • model uses the Logit model for mode choice.
  • mu controls the randomness in mode select. Lower values means that agents are more likely to choose the maximum-utility mode.

Tip

For more advanced scenarios, you can include ridesharing as an additional mode. This extension is explored further in the Extensions section.

Preferences for each mode are defined in the modes.* tables. Each mode includes two parameters.

  • alpha represents the value of time (in euros per hour).
  • constant represents a fixed cost associated with choosing the mode.
[modes]
[modes.car_driver]
constant = 0
alpha = 10

[modes.public_transit]
constant = 2.0
alpha = 15.0
road_network_speed = 40

The parameter road_network_speed simplifies public-transit travel time calculation by assuming a constant speed on the road network, avoiding the need to define a separate public-transit network. A value of 40 km/h matches the assumption from de Palma et al. (2005).

As in the Bottleneck case study, you can include schedule-delay penalties in the utility function using the departure_time.linear_schedule table. These penalties influence agents’ departure-time choice based on their desired arrival time.

  • beta defines the penalty for early arrivals (in euros per hour).
  • gamma defines the penalty for late arrivals (in euros per hour).
  • tstar represent the desired arrival time at destination, specified in HH:MM:SS format.
[departure_time.linear_schedule]
beta = 6
gamma = 25
tstar = { mean = 08:00:00, std = 1800, distribution = "Uniform" }

This configuration means that agents have a desired arrival time uniformly distributed between 07:30:00 and 08:30:00. Late arrivals incur a higher penalty (25 euros per hour) compared to early arrivals (6 euros per hour).

To model how agents choose their departure time, it is recommended using the Continuous Logit model, which aligns with the approach in de Palma et al. (2005).

Set departure_time_choice.model to "ContinuousLogit" and use departure_time_choice.mu to control the randomness in agents’ choices: smaller values make agents choose departure times closer to the utility-maximizing value; larger values introduce more randomness in their choice.

[departure_time_choice]
model = "ContinuousLogit"
mu = 1

Simulation

Before running the simulation, configure the final parameters in the simulation table.

[simulation]
period = [06:00:00, 10:00:00]
recording_interval = 300
learning_factor = 0.005
nb_iterations = 200

The simulation.period parameter defines the time window of the simulation, specified as a list of start and end times. This period constrains when agents can depart.

For this simulation, focused on the morning peak period (with desired arrival times between 07:30 and 08:30), a period from 6 a.m. to 10 a.m. provides a sufficiently wide window for all agents to depart and reach their destinations.

Tip

If you notice a peak of departures at the very start or end of the simulation period, it may indicate that the period is too narrow. Extend the window to allow agents to choose departure times that align better with their desired arrival times.

The simulation.recording_interval parameter controls the time between breakpoints in the piecewise linear functions used to store travel times. A value of 300 seconds (5 minutes) offers a good balance between simulation speed and accuracy in travel-time functions.

To ensure the simulation converges satisfactorily, two key parameters are defined.

  • simulation.learning_factor controls how quickly agents learns about travel times. A value of 0.005 is recommended for this simulation.
  • simulation.nb_iterations determines the number of iterations the simulation runs. A value of 200 is typically sufficient.

Tip

For simulations with higher congestion, consider decreasing the learning factor or increasing the number of iterations to improve the convergence quality.

For more details on simulation convergence, refer to the relevant page.

Before running your simulation, ensure you have downloaded the last version of Metropolis-Core and configured the metropolis_core.exec_path parameter to point to the metropolis_cli executable. By convention, the executable is typically placed in an execs/ directory within the directory where Pymetropolis is executed.

[metropolis_core]
exec_path = "execs/metropolis_cli"

Running

Once your configuration file is ready, you can execute Pymetropolis directly from the terminal. Simply run the pymetropolis command, specifying the path to your configuration file as an argument.

Optionally, before running the full simulation, you can check what Pymetropolis is planning to do by using the --dry-run option. This will check for typos or issues and display the list of steps that will be executed:

pymetropolis config-circular-city.toml --dry-run

If there everything is correctly configured, you should see an output similar to this.

1. WriteMetroParametersStep
2. CircularNetworkStep
3. PostprocessRoadNetworkStep
4. AllRoadDistancesStep
5. ExogenousCapacitiesStep
6. EdgesFreeFlowTravelTimesStep
7. AllFreeFlowTravelTimesStep
8. GravityODMatrixStep
9. CarFreeFlowDistancesStep
10. CarShortestDistancesStep
11. GenericPopulationStep
12. WriteMetroEdgesStep
13. WriteMetroVehicleTypesStep
14. CarDriverPreferencesStep
15. PublicTransitPreferencesStep
16. PublicTransitTravelTimesFromRoadDistancesStep
17. RoadODMatrixStep
18. LinearScheduleStep
19. HomogeneousTstarStep
20. WriteMetroTripsStep
21. UniformDrawsStep
22. WriteMetroAgentsStep
23. WriteMetroAlternativesStep
24. RunSimulationStep
25. IterationResultsStep
26. ConvergencePlotStep
27. RoadNetworkCongestionFunctionPlotsStep
28. TripResultsStep
29. TripDepartureTimeDistributionStep

If the list of steps looks correct, you can proceed with the actual simulation by removing the --dry-run option.

pymetropolis config-circular-city.toml

The simulation should complete in about 20 minutes.

Convergence

Before diving into the results, the first step is to verify whether the simulation has converged satisfactorily. If the convergence is poor, further analysis may not be meaningful, and you may need to adjust the number of iterations or the learning factor (See Simulation convergence).

Pymetropolis automatically generates convergence graphs in the results/graphs/ directory (located within the main_directory you specified). These graphs are prefixed with convergence_ and fall into two main categories.

Convergence of departure times Convergence of mean surplus

Results

Pymetropolis generates a variety of output files that you can use to analyze the simulation results. Some of the most relevant files are:

Feel free to explore these files to generate your own tables and graphs.

Pymetropolis also produces graphs to visualize key aspects of the simulation:

Distribution of departure times Congestion function

Complete configuration

main_directory = "circular-city/"

random_seed = 123454321

[circular_network]
nb_radials = 8
nb_rings = 4
radius = 4000

[road_network]
[road_network.default_speed_limit]
"Radial 1" = 50
"Radial 2" = 70
"Radial 3" = 70
"Radial 4" = 70
"Ring 1" = 50
"Ring 2" = 50
"Ring 3" = 50
"Ring 4" = 50

[road_network.default_nb_lanes]
"Radial 1" = 2
"Radial 2" = 2
"Radial 3" = 2
"Radial 4" = 2
"Ring 1" = 1
"Ring 2" = 1
"Ring 3" = 1
"Ring 4" = 1

[road_network.capacities]
"Radial 1" = 1500
"Radial 2" = 2000
"Radial 3" = 2000
"Radial 4" = 2000
"Ring 1" = 2000
"Ring 2" = 2000
"Ring 3" = 2000
"Ring 4" = 2000

[gravity_od_matrix]
exponential_decay = 0.07
trips_per_node = 8000

[mode_choice]
modes = ["car_driver", "public_transit"]
model = "Logit"
mu = 1

[modes]
[modes.car_driver]
constant = 0
alpha = 10

[modes.public_transit]
constant = 2
alpha = 15
road_network_speed = 40

[departure_time_choice]
model = "ContinuousLogit"
mu = 1

[departure_time.linear_schedule]
beta = 6
gamma = 25
tstar = { mean = 08:00:00, std = 1800, distribution = "Uniform" }

[simulation]
period = [06:00:00, 10:00:00]
recording_interval = 300
learning_factor = 0.005
nb_iterations = 200

[metropolis_core]
exec_path = "execs/metropolis_cli"

Extensions

Introducing ridesharing

In the current setup, agents traveling by car are assumed to be driving alone, with each agent generating congestion equivalent to a single car. However, in reality, people with similar origins and destinations often share rides to save on costs like fuel.

METROPOLIS2 proposes the "car_ridesharing" mode to model ridesharing. This mode represents agents traveling by car with others, without distinguishing between drivers and passengers.

To include this mode as an option for the agents, update the mode_choice.modes parameter.

[mode_choice]
modes = ["car_driver", "car_ridesharing", "public_transit"]

Note

The "car_ridesharing" mode is a basic representation of ridesharing in METROPOLIS2:

  • It does not explicitly model the matching between drivers and passengers.
  • It does not guarantee that agents choosing ridesharing have a compatible partner with the same origin, destination, and departure time.
  • It does not account for detours drivers might take to pick up or drop off passengers.

For advanced ridesharing modeling in METROPOLIS2, including matching, refer to Ghoslya et al. (2025).

You can define preferences for the "car_ridesharing" mode different from the ones for "car_driver".

  • constant represents the fixed cost of ridesharing (e.g., difficulty finding a match, detours, waiting time).
  • alpha represents the value of time (which can be different from car alone due to inconvenience).
[modes.car_ridesharing]
constant = 2.0
alpha = 11.0

Since ridesharing involves multiple persons per car, the congestion generated must be adjusted. METROPOLIS2 simulates one car per ridesharing agent, but you can modify the Passenger Car Equivalent (PCE) and headway to reflect the reduced congestion impact of shared rides.

  • If ridesharing agents travel in pairs, PCE and headway must be divided by 2.
  • If they travel in groups of three, PCE and headway must be divided by 3.
  • In general, if the average number of passengers per ridesharing car is n, PCE and headway must be divided by 1 + n.

The average number of passengers is controlled by the vehicle_types.car.ridesharing_passenger_count parameter. For France, this value is approximately 1.2.

[vehicle_types]
[vehicle_types.car]
ridesharing_passenger_count = 1.2

Tip

You can also use the "car_ridesharing" mode as a complete substitute for "car_driver" if you want to model ridesharing without distinguishing between solo and shared trips. In this case, you need to assume the same constant cost and value of time for all car trips. Additionally, ridesharing_passenger_count must be set to the average number of passengers per car (including solo drivers). For France, this value is around 0.4 (or lower during peak hours).

However, this approach is not adapted when you want to model different preferences for solo and shared trips and when you want to simulate HOV lanes.

Mode shares with ridesharing

Figure 4. Mode shares with the "car_ridesharing" option.

Adding fuel costs to car trips

In the current setup, ridesharing offers no clear individual benefit in the model:

  • Both the constant cost and the value of time are larger than for car alone.
  • Travel times are identical for solo and shared trips.

The only reason some agents choose "car_ridesharing" is due to the Logit model’s randomness, which introduces variability in mode choice.

In reality, ridesharing is attractive because it allows sharing costs (e.g., fuel, tolls) among participants. METROPOLIS2 can model fuel costs using two parameters.

  • fuel.consumption_factor represents the fuel consumption rate for car trips, in liters per kilometer. A value of 0.08 equals a consumption of 8 liters per 100 km.
  • fuel.price defines the price per liter of fuel, in euros.
[fuel]
consumption_factor = 0.08
price = 1.8

With this configuration, fuel cost are included in the utility function for both "car_driver" and "car_ridesharing". However, for "car_ridesharing", the fuel cost is divided by the ridesharing_passenger_count, which can make ridesharing more attractive than driving alone.

Caution

METROPOLIS2 currently does not compute fuel consumption dynamically based on the selected route during each iteration. Instead, it uses pre-simulation free-flow consumption.

When fuel costs are included, the model shows an increase in both ridesharing and public-transit use.

Mode shares with fuel costs

Figure 5. Mode shares when including fuel costs.

Enhancing ridesharing with HOV lanes

High-Occupancy Vehicle (HOV) lanes are an effective way to make ridesharing more attractive. These lanes are restricted to cars with at least two passengers (including the driver). If they are less congested than regular lanes, they can reduce travel times for ridesharing user compared to solo drivers.

HOV lanes are defined using the road_network.hov_lanes parameter. This parameter works similarly to road_network.nb_lanes: you can specify a single value (applied to all edges) or you can use a table to define HOV lanes for specific road types.

[road_network.hov_lanes]
"Ring 1" = 1
"Ring 2" = 1

In his example, edges with road types "Ring 1" and "Ring 2" have one HOV lane, while, for other road types, the default value (no HOV lane) is used.

Caution

The road_network.hov_lanes does not add extra lanes to edges. Instead, it reserves lanes from the total defined by road_network.nb_lanes. An edge cannot have more HOV lanes than its total lanes (hov_lanes ≤ nb_lanes).

You can create edges with only HOV lanes (nb_lanes = hov_lanes), effectively banning solo drivers from those edges. However, ensure that solo drivers can still reach their destination via alternative routes.

Tip

Both road_network.nb_lanes and road_network.hov_lanes support non-integer values. For example, you can set hov_lanes = 0.5 to model HOV lanes in a “continuous” way.

Mode shares with HOV lanes

Figure 6. Mode shares with HOV lanes.

Matching constant

Coming soon.

Nested Logit for mode choice

Coming soon-ish.

Complete configuration with ridesharing

main_directory = "circular-city-ridesharing/"

random_seed = 123454321

[circular_network]
nb_radials = 8
nb_rings = 4
radius = 4000

[road_network]
[road_network.default_speed_limit]
"Radial 1" = 50
"Radial 2" = 50
"Radial 3" = 50
"Radial 4" = 50
"Ring 1" = 50
"Ring 2" = 70
"Ring 3" = 70
"Ring 4" = 70

[road_network.default_nb_lanes]
"Radial 1" = 1
"Radial 2" = 1
"Radial 3" = 1
"Radial 4" = 1
"Ring 1" = 2
"Ring 2" = 2
"Ring 3" = 2
"Ring 4" = 2

[road_network.capacities]
"Radial 1" = 1500
"Radial 2" = 2000
"Radial 3" = 2000
"Radial 4" = 2000
"Ring 1" = 2000
"Ring 2" = 2000
"Ring 3" = 2000
"Ring 4" = 2000

[road_network.hov_lanes]
"Ring 1" = 1
"Ring 2" = 1

[gravity_od_matrix]
exponential_decay = 0.07
trips_per_node = 8000

[mode_choice]
modes = ["car_driver", "car_ridesharing", "public_transit"]
model = "Logit"
mu = 1.0

[modes]
[modes.car_driver]
constant = 0.0
alpha = 10.0

[modes.car_ridesharing]
constant = 2.0
alpha = 11.0

[modes.public_transit]
constant = 2.0
alpha = 15.0
road_network_speed = 40

[departure_time_choice]
model = "ContinuousLogit"
mu = 1.0

[departure_time.linear_schedule]
beta = 6.0
gamma = 25.0
tstar = { mean = 08:00:00, std = 1800, distribution = "Uniform" }

[vehicle_types]
[vehicle_types.car]
ridesharing_passenger_count = 1.2

[fuel]
consumption_factor = 0.08
price = 1.8

[simulation]
period = [06:00:00, 10:00:00]
recording_interval = 300
learning_factor = 0.005
nb_iterations = 200

[metropolis_core]
exec_path = "execs/metropolis_cli"

References

de Palma, A., Kilani, M., & Lindsey, R. (2005). Congestion pricing on a road network: A study using the dynamic equilibrium simulator METROPOLIS. Transportation Research Part A: Policy and Practice, 39(7-9), 588-611.

Ghoslya, S., Javaudin, L., Palma, A. D., & Delle Site, P. (2025). Ride-sharing, congestion, departure-time and mode choices: A social optimum perspective. Available at SSRN 5465467.