Simulating traffic around a lock (with hydrodynamics)#

In this notebook, we simulate a lock on a network which two opposingly directed vessels have to pass. We add a pre-coded complex lock object on the graph. Vessels are locked together if they can fit inside the lock, and arrive within the clustering time window. Changing water levels are implemented, which influences the duration of the levelling.

0. Import libraries#

# package(s) used for creating and geo-locating the graph
import networkx as nx
import pyproj
from shapely.geometry import Point, LineString
from shapely.ops import transform

# package(s) related to the simulation (creating the vessel, running the simulation)
import datetime
import simpy
import opentnsim
from opentnsim.core.logutils import logbook2eventtable
from opentnsim.core.plotutils import generate_vessel_gantt_chart
from scipy.stats import norm, uniform, expon

# import of modules important for locking
from opentnsim.lock import lock as lock_module
from opentnsim.vessel_traffic_service import vessel_traffic_service as vessel_traffic_service_module
from opentnsim.graph import mixins as graph_module

# package(s) needed for inspecting the output
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt

# packege(s) needed to create hydrodynamic data
import xarray as xr

print("This notebook is executed with OpenTNSim version {}".format(opentnsim.__version__))
This notebook is executed with OpenTNSim version 1.3.7

1. Define object classes#

# make your preferred Vessel class out of available mix-ins.
Vessel = type(
    "Vessel", 
    (
        lock_module.PassesLockComplex,             # allows to interact with a lock
        opentnsim.core.Identifiable,               # allows to give the object a name and a random ID,
        opentnsim.core.Movable,                    # allows the object to move, with a fixed speed, while logging this activity
        opentnsim.core.VesselProperties,           # allows vessel to have dimensions, namely a length (L), width (B), and draught (T)
        opentnsim.core.ExtraMetadata,              # allow additional information, such as an arrival time (required for passing a lock)
        opentnsim.mixins.HasMultiDiGraph,           # allow to operate on a graph that can include parallel edges from and to the same nodes
        opentnsim.output.HasOutput,                # allow additional output to be stored
    ), 
    {}
)

2. Create graph#

# define reference systems
wgs84eqd = pyproj.CRS('4087')
wgs84rad = pyproj.CRS('4326')

# define transformer functions
wgs84eqd_to_wgs84rad = pyproj.transformer.Transformer.from_crs(wgs84eqd,wgs84rad,always_xy=True).transform #equidistant wgs84 to radial wgs84
wgs84rad_to_wgs84eqd = pyproj.transformer.Transformer.from_crs(wgs84rad,wgs84eqd,always_xy=True).transform #radial wgs84 to equidistant wgs84

# create a directed graph
graph = nx.DiGraph()

# add nodes
graph.add_node('-1',geometry=transform(wgs84eqd_to_wgs84rad,Point(-350600,0)))
graph.add_node('0',geometry=transform(wgs84eqd_to_wgs84rad,Point(-5000,0)))
graph.add_node('1',geometry=transform(wgs84eqd_to_wgs84rad,Point(5000,0)))
graph.add_node('+1',geometry=transform(wgs84eqd_to_wgs84rad,Point(350600,0)))

# add edges
graph.add_edge('-1','0', weight=1)
graph.add_edge('0','-1', weight=1)
graph.add_edge('0','1', weight=1)
graph.add_edge('1','0', weight=1)
graph.add_edge('1','+1', weight=1)
graph.add_edge('+1','1', weight=1)
graph_module.plot_graph(graph)

3. Run simulation#

def generate_vessel(
    env,
    name,
    start_node,
    end_node,
    arrival_time,
    vessel_speed=4,
    vessel_length=100,
    vessel_beam=20,
    vessel_draft=10,
    vessel_type="tanker"):
    """
    Creates and returns a Vessel object with a computed route through the environment graph.

    Parameters:
    ----------
    env : Environment
        The simulation environment containing the graph and other context.
    name : str
        Human readabile identifier for the vessel.
    start_node : str or int
        The starting node in the graph (converted to string).
    end_node : str or int
        The destination node in the graph (converted to string).
    arrival_time : pd.Timestamp
        The scheduled arrival time of the vessel at the start node.
    vessel_speed : float, optional
        Speed of the vessel in knots or simulation units (default is 4).
    vessel_length : float, optional
        Length of the vessel in meters (default is 100).
    vessel_beam : float, optional
        Beam (width) of the vessel in meters (default is 20).
    vessel_draft : float, optional
        Draught (depth below waterline) of the vessel in meters (default is 10).
    vessel_type : str, optional
        Type of vessel (e.g., "tanker", "cargo", "container") (default is "tanker").

    Returns:
    -------
    Vessel or None
        A Vessel object initialized with the given parameters and route.
        Returns None if no valid path exists between start_node and end_node.
    """
    
    # Ensure nodes are strings
    start_node = str(start_node)
    end_node = str(end_node)

    try:
        route = nx.dijkstra_path(env.graph, start_node, end_node)
    except nx.NetworkXNoPath:
        print(f"⚠️ No path from {start_node} to {end_node}. Vessel {name} not created.")
        return None

    geometry = env.graph.nodes[start_node]['geometry']

    data_vessel = {
        "env": env,
        "name": name,
        "geometry": geometry,
        "route": route,
        "v": vessel_speed,
        "L": vessel_length,
        "B": vessel_beam,
        "T": vessel_draft,
        "type": vessel_type,
        "arrival_time": arrival_time,
    }

    vessel = Vessel(**data_vessel)

    return vessel
def generate_vessels_with_distributions(
    env,
    num_vessels,
    start_time,
    arrival_dist_up=None,
    arrival_dist_down=None,
    seed_up=None,
    seed_down=None):
    """
    Generates a list of vessels with interarrival times drawn from specified distributions
    for upward and downward directions. Supports independent seeding for reproducibility.

    Parameters
    ----------
    env : Environment
        The simulation environment containing the graph and vessel context.
    num_vessels : int
        Total number of vessels to generate. Vessels alternate between up and down directions.
    start_time : pd.Timestamp
        The initial timestamp from which vessel arrivals begin.
    arrival_dist_up : callable, optional
        A function returning interarrival times (in minutes) for upward-moving vessels.
        If None, defaults to an exponential distribution with mean 20 minutes.
    arrival_dist_down : callable, optional
        A function returning interarrival times (in minutes) for downward-moving vessels.
        If None, defaults to an exponential distribution with mean 20 minutes.
    seed_up : int or None, optional
        Seed for the random number generator used in upward direction.
    seed_down : int or None, optional
        Seed for the random number generator used in downward direction.

    Returns
    -------
    list of Vessel
        A list of Vessel objects with assigned routes and arrival times.
        Vessels for which no valid path exists are skipped.
    """

    vessels = []

    # Create independent random generators
    rng_up = np.random.default_rng(seed_up)
    rng_down = np.random.default_rng(seed_down)

    # Default to exponential distribution with mean 20 minutes
    if arrival_dist_up is None:
        arrival_dist_up = lambda: rng_up.exponential(scale=20)
    if arrival_dist_down is None:
        arrival_dist_down = lambda: rng_down.exponential(scale=20)

    up_time = start_time
    down_time = start_time

    for i in range(num_vessels):
        if i % 2 == 0:
            # Upward direction: -1 → +1
            start_node, end_node = "-1", "+1"
            delta_minutes = arrival_dist_up()
            arrival_time = up_time + pd.Timedelta(minutes=delta_minutes)
            up_time = arrival_time
        else:
            # Downward direction: +1 → -1
            start_node, end_node = "+1", "-1"
            delta_minutes = arrival_dist_down()
            arrival_time = down_time + pd.Timedelta(minutes=delta_minutes)
            down_time = arrival_time

        vessel = generate_vessel(
            env=env,
            name=f"Vessel {i + 1}",
            start_node=start_node,
            end_node=end_node,
            arrival_time=arrival_time
        )

        if vessel:
            vessels.append(vessel)

    return vessels
def mission(env, vessel):
    """
    Method that defines the mission of the vessel.
    
    In this case: 
        keep moving along the path until its end point is reached
    """
    while True:
        yield from vessel.move()
        
        if vessel.geometry == nx.get_node_attributes(env.graph, "geometry")[vessel.route[-1]]:
            break
# start simpy environment
simulation_start = datetime.datetime(2025, 1, 1, 0, 0, 0)
env = simpy.Environment(initial_time=simulation_start.timestamp())
env.epoch = simulation_start
# generate synthetic hydrodynamic data
hydrodynamic_data = xr.Dataset()

time = pd.date_range(start = simulation_start,
                     end = simulation_start+pd.Timedelta(days=7),
                     freq = pd.Timedelta(minutes=5))

static_water_level = np.zeros(len(time))
tidal_amplitude = 1.0
tidal_period = 12.5 #hours
tidal_water_level = [tidal_amplitude*np.sin(2*np.pi*(t-simulation_start).total_seconds()/(tidal_period*3600)) for t in time]

stations = ['0', '1']
water_level_data = xr.DataArray(data=[static_water_level,tidal_water_level],coords={'STATION':stations,'TIME':time})

hydrodynamic_data['Water level'] = water_level_data
# add graph to environment
env.graph = graph

# add components important for locking to the environment
env.vessel_traffic_service = vessel_traffic_service_module.VesselTrafficService(graph = graph, 
                                                                                crs_m="4087",
                                                                                hydrodynamic_information = hydrodynamic_data)

lock = lock_module.IsLockComplex(
    env=env,
    name='Lock',
    node_open='0',
    node_A = '0',
    node_B = '1',
    distance_lock_doors_A_to_waiting_area_A = 4800,
    distance_lock_doors_B_to_waiting_area_B = 4800,
    distance_from_start_node_to_lock_doors_A = 4800,
    distance_from_end_node_to_lock_doors_B = 4800,
    lock_length = 400,
    lock_width = 50,
    lock_depth = 15,
    levelling_time = 300,
    sailing_distance_to_crossing_point = 1800,
    doors_opening_time= 300,
    doors_closing_time= 300,
    speed_reduction_factor_lock_chamber=0.5,
    sailing_in_time_gap_through_doors = 300,
    sailing_in_speed_sea = 1.5,
    sailing_in_speed_canal = 1.5,
    sailing_out_time_gap_through_doors = 120,
    sailing_time_before_opening_lock_doors = 600,
    sailing_time_before_closing_lock_doors = 120,
    registration_nodes = ['-1','+1'],
)

start_time = pd.Timestamp('2025-01-01 00:00:00')

# Example using numpy
# Create independent random generators
rng_up = np.random.default_rng(123)
rng_down = np.random.default_rng(456)

# Exponential distributions with different means (scale = mean)
arrival_dist_up = lambda: rng_up.exponential(scale=30)   # mean 30 minutes
arrival_dist_down = lambda: rng_down.exponential(scale=15)  # mean 15 minutes

vessels = generate_vessels_with_distributions(
    env=env,
    num_vessels=9,
    start_time=start_time,
    arrival_dist_up=arrival_dist_up,
    arrival_dist_down=arrival_dist_down,
    seed_up=123,
    seed_down=456
)

for vessel in vessels:
    env.process(mission(env, vessel))
    
env.run()
lock.operation_planning
node_from node_to direction lock_chamber vessels capacity_L capacity_B time_potential_lock_door_opening_stop time_operation_start time_entry_start ... time_door_opening_stop time_departure_start time_departure_stop time_operation_stop time_potential_lock_door_closure_start wlev_A wlev_B maximum_individual_delay total_delay status
lock_operation
0 0 1 0 Lock [] 400 50 2025-01-01 00:07:22.177207 2025-01-01 00:07:22.177207 2025-01-01 00:07:22.177207 ... 2025-01-01 00:20:02.177207 2025-01-01 00:20:02.177207 2025-01-01 00:20:02.177207 2025-01-01 00:20:02.177207 2025-01-01 00:20:02.177207 0.0 0.12533323356430426 0 days 00:00:00 0 days 00:00:00 available
1 1 0 1 Lock [<__main__.Vessel object at 0x7f8f8d8ad810>, <... 0 -30 2025-01-02 00:17:22.177207 2025-01-02 00:19:52.177207 2025-01-02 00:27:22.177207 ... 2025-01-02 00:58:06.301059352 2025-01-02 00:58:06.301059352 2025-01-02 01:08:09.281621116 2025-01-02 01:15:39.281621116 2025-01-02 01:10:09.281621116 0.0 -0.0836778433323161 0 days 00:08:43.951065882 0 days 00:16:46.623747764 unavailable
2 0 1 0 Lock [<__main__.Vessel object at 0x7f8f8d879e80>, <... 0 -30 2025-01-02 01:13:09.281621116 2025-01-02 01:15:39.281621116 2025-01-02 01:23:09.281621116 ... 2025-01-02 02:02:08.050519938 2025-01-02 02:02:08.050519938 2025-01-02 02:12:11.031081702 2025-01-02 02:19:41.031081702 2025-01-02 02:14:11.031081702 0.0 0.44463517918492684 0 days 00:59:23.327242468 0 days 01:19:48.875246350 unavailable
3 1 0 1 Lock [] 400 50 2025-01-02 02:14:11.031081702 2025-01-02 02:14:11.031081702 2025-01-02 02:14:11.031081702 ... 2025-01-02 02:42:21.031081702 2025-01-02 02:42:21.031081702 2025-01-02 02:42:21.031081702 2025-01-02 02:42:21.031081702 2025-01-02 02:42:21.031081702 0.0 0.6211477802783093 0 days 00:00:00 0 days 00:00:00 unavailable
4 0 1 0 Lock [<__main__.Vessel object at 0x7f8f8d8ae350>] 300 30 2025-01-02 02:42:21.031081702 2025-01-02 02:44:51.031081702 2025-01-02 02:52:21.031081702 ... 2025-01-02 03:30:41.203868172 2025-01-02 03:30:41.203868172 2025-01-02 03:31:29.799980524 2025-01-02 03:38:59.799980524 2025-01-02 03:33:29.799980524 0.0 0.9372819894918916 0 days 01:51:23.011631702 0 days 01:51:23.011631702 unavailable

5 rows × 26 columns

lock.vessel_planning
id node_from node_to lock_chamber L B T operation_index time_of_registration time_of_acceptance ... time_arrival_at_lineup_area time_lock_passing_start time_lock_entry_start time_lock_entry_stop time_lock_departure_start time_lock_departure_stop time_lock_passing_stop time_potential_lock_door_closure_start direction delay
0 8ce2029c-fa47-401c-b0b5-90524d2fe93a 1 0 NaN 100 20 10 1 2025-01-01 00:07:22.177207 2025-01-01 00:07:22.177207 ... NaN 2025-01-02 00:19:52.177207 2025-01-02 00:27:22.177207 2025-01-02 00:33:02.349993470 2025-01-02 00:58:06.301059352 2025-01-02 00:58:54.897171704 2025-01-02 01:06:24.897171704 2025-01-02 00:33:02.349993470 1.0 0 days 00:08:43.951065882
1 7d17a715-cd7c-444b-8000-5b452c1cb634 1 0 Lock 100 20 10 1 2025-01-01 00:13:40.580269 2025-01-01 00:13:40.580269 ... NaN 2025-01-02 00:26:10.580269 2025-01-02 00:33:40.580269 2025-01-02 00:37:43.560830764 2025-01-02 00:58:29.108834646 2025-01-02 01:00:54.897171704 2025-01-02 01:08:24.897171704 2025-01-02 00:37:43.560830764 1.0 0 days 00:05:42.740228588
2 6dfef98e-eca6-40da-9f51-587bc773e5ac 1 0 Lock 100 20 10 1 2025-01-01 00:17:13.532738 2025-01-01 00:17:13.532738 ... NaN 2025-01-02 00:31:10.580269 2025-01-02 00:38:40.580269 2025-01-02 00:41:06.368606058 2025-01-02 01:00:29.108834646 2025-01-02 01:04:32.089396410 2025-01-02 01:12:02.089396410 2025-01-02 00:41:06.368606058 1.0 0 days 00:02:19.932453294
3 38494a88-7d27-4a8c-9a31-c1b85e1f3809 0 1 NaN 100 20 10 2 2025-01-01 00:17:54.550491 2025-01-01 00:17:54.550491 ... NaT 2025-01-02 01:15:39.281621116 2025-01-02 01:23:09.281621116 2025-01-02 01:28:49.454407586 2025-01-02 02:02:08.050519938 2025-01-02 02:02:56.646632290 2025-01-02 02:10:26.646632290 2025-01-02 01:28:49.454407586 0.0 0 days 00:59:23.327242468
4 b4eddd03-3a12-47bb-92fe-859844831413 0 1 Lock 100 20 10 2 2025-01-01 00:21:25.206401 2025-01-01 00:21:25.206401 ... NaT 2025-01-02 01:21:19.454407586 2025-01-02 01:28:49.454407586 2025-01-02 01:32:52.434969350 2025-01-02 02:02:30.858295232 2025-01-02 02:04:56.646632290 2025-01-02 02:12:26.646632290 2025-01-02 01:32:52.434969350 0.0 0 days 00:16:02.740228588
5 147044f7-e87b-4615-92d0-6dfbbf15cdc2 1 0 Lock 100 20 10 1 2025-01-01 00:24:37.704947 2025-01-01 00:24:37.704947 ... NaN 2025-01-02 00:37:07.704947 2025-01-02 00:44:37.704947 2025-01-02 00:45:26.301059352 2025-01-02 01:02:29.108834646 2025-01-02 01:08:09.281621116 2025-01-02 01:15:39.281621116 2025-01-02 00:45:26.301059352 1.0 0 days 00:00:00
6 780dfbe3-c7d1-4531-ad72-7422d1653ff9 0 1 Lock 100 20 10 2 2025-01-01 00:28:58.436860 2025-01-01 00:28:58.436860 ... NaN 2025-01-02 01:26:19.454407586 2025-01-02 01:33:49.454407586 2025-01-02 01:36:15.242744644 2025-01-02 02:04:30.858295232 2025-01-02 02:08:33.838856996 2025-01-02 02:16:03.838856996 2025-01-02 01:36:15.242744644 0.0 0 days 00:04:22.807775294
7 e754af16-217e-4730-ac95-8c6edd76527f 0 1 Lock 100 20 10 2 2025-01-01 00:38:12.774662 2025-01-01 00:38:12.774662 ... NaN 2025-01-02 01:31:19.454407586 2025-01-02 01:38:49.454407586 2025-01-02 01:39:38.050519938 2025-01-02 02:06:30.858295232 2025-01-02 02:12:11.031081702 2025-01-02 02:19:41.031081702 2025-01-02 01:39:38.050519938 0.0 0 days 00:00:00
8 102ff1c0-bf3a-4245-a1a9-4ec3e2bb80b4 0 1 NaN 100 20 10 4 2025-01-01 00:40:58.019450 2025-01-01 00:40:58.019450 ... NaN 2025-01-02 02:44:51.031081702 2025-01-02 02:52:21.031081702 2025-01-02 02:58:01.203868172 2025-01-02 03:30:41.203868172 2025-01-02 03:31:29.799980524 2025-01-02 03:38:59.799980524 2025-01-02 02:58:01.203868172 0.0 0 days 01:51:23.011631702

9 rows × 22 columns

4. Inspect output#

# load the logbook data into a dataframe
lock_df = pd.DataFrame.from_dict(lock.lock_chamber.logbook)

print("'{}' logbook data:".format(lock.name))  
print('')

display(lock_df)
'Lock' logbook data:
Message Timestamp Value Geometry
0 Lock doors closing start 2025-01-01 00:07:22.177207 {} 0
1 Lock doors closing stop 2025-01-01 00:12:22.177207 {} 0
2 Lock chamber converting start 2025-01-01 00:12:22.177207 {} 0
3 Lock chamber converting stop 2025-01-01 00:15:02.177207 {} 1
4 Lock doors opening start 2025-01-01 00:15:02.177207 {} 1
5 Lock doors opening stop 2025-01-01 00:20:02.177207 {} 1
6 Lock doors closing start 2025-01-02 00:45:26.301059 {} 1
7 Lock doors closing stop 2025-01-02 00:50:26.301059 {} 1
8 Lock chamber converting start 2025-01-02 00:50:26.301059 {} 1
9 Lock chamber converting stop 2025-01-02 00:53:06.301059 {} 0
10 Lock doors opening start 2025-01-02 00:53:06.301059 {} 0
11 Lock doors opening stop 2025-01-02 00:58:06.301059 {} 0
12 Lock doors closing start 2025-01-02 01:39:38.050519 {} 0
13 Lock doors closing stop 2025-01-02 01:44:38.050519 {} 0
14 Lock chamber converting start 2025-01-02 01:44:38.050519 {} 0
15 Lock chamber converting stop 2025-01-02 01:57:08.050519 {} 1
16 Lock doors opening start 2025-01-02 01:57:08.050519 {} 1
17 Lock doors opening stop 2025-01-02 02:02:08.050519 {} 1
18 Lock doors closing start 2025-01-02 02:14:11.031082 {} 1
19 Lock doors closing stop 2025-01-02 02:19:11.031082 {} 1
20 Lock chamber converting start 2025-01-02 02:19:11.031082 {} 1
21 Lock chamber converting stop 2025-01-02 02:37:21.031082 {} 0
22 Lock doors opening start 2025-01-02 02:37:21.031082 {} 0
23 Lock doors opening stop 2025-01-02 02:42:21.031082 {} 0
24 Lock doors closing start 2025-01-02 02:58:01.203867 {} 0
25 Lock doors closing stop 2025-01-02 03:03:01.203867 {} 0
26 Lock chamber converting start 2025-01-02 03:03:01.203867 {} 0
27 Lock chamber converting stop 2025-01-02 03:25:41.203867 {} 1
28 Lock doors opening start 2025-01-02 03:25:41.203867 {} 1
29 Lock doors opening stop 2025-01-02 03:30:41.203867 {} 1

Gantt chart of event table#

df_eventtable = opentnsim.core.logutils.logbook2eventtable([*vessels, lock.lock_chamber])
fig = generate_vessel_gantt_chart(df_eventtable)

Time-distance diagram of vessels passing the lock and planning info#

def cm_to_pixels(cm):
    return cm * 37.8 # Set figure height to 10 cmfig.update_layout(height=cm_to_pixels(10))

# We can plot the time-distance diagram
fig = lock.create_time_distance_plot(vessels = vessels, 
                                     xlimmin = -6050, 
                                     xlimmax = 6050,
                                     ylimmin = pd.Timestamp('2025-01-01 22:00:00'),
                                     ylimmax = pd.Timestamp('2025-01-02 09:00:00'),
                                     method='Plotly')

fig.update_layout(height=cm_to_pixels(20))

Levelling times as a function of water levels#

fig,ax = plt.subplots(figsize=[12,8])
ax.plot(hydrodynamic_data["TIME"],hydrodynamic_data.sel({'STATION':'1'})["Water level"])
ax.plot(hydrodynamic_data["TIME"],lock.lock_chamber.water_level,color='k')
ax.set_xlim([pd.Timestamp('2025-01-02'),pd.Timestamp('2025-01-02 06:00')])
(np.float64(20090.0), np.float64(20090.25))
../_images/4191112def14bd57e2908a34221b62bbcc6b3b2ab37075f5427cd251b5fa4148.png
levelling_times = []
water_level_difference = []
stationA_index = np.where(np.array(list((hydrodynamic_data['STATION'].values))) == lock.start_node)[0][0]
stationB_index = np.where(np.array(list((hydrodynamic_data['STATION'].values))) == lock.end_node)[0][0]
H_A = hydrodynamic_data["Water level"][stationA_index].values.copy()
H_B = hydrodynamic_data["Water level"][stationB_index].values.copy()
for time in pd.date_range(pd.Timestamp('2025-01-01T00:00:00.000000000'),pd.Timestamp('2025-01-01T12:30:00.000000000'),freq=pd.Timedelta(minutes=5)):
    time_index = np.absolute(hydrodynamic_data['TIME'][:].values - np.datetime64(time)).argmin()
    wlev_A = H_A[time_index]
    wlev_B = H_B[time_index]
    wlev_dif = wlev_A - wlev_B
    water_level_difference.append(wlev_dif)
    levelling_times.append(lock.determine_levelling_time(time,direction=1,wlev_init=wlev_dif,prediction=True)[0])
    
plt.plot(water_level_difference,np.array(levelling_times)/60)
plt.xlabel('Water level difference [m]')
plt.ylabel('Levelling time [min]');
../_images/313689dea282c6441f9c420041ccca4312d2767b0ffdecd2821e0415f62af521.png