Basic trip over a real-world graph#

In this notebook, we set up a basic simulation where one vessel moves over a real-world graph. The vessel will sail from a location in the Maasvlakte, to a location in the Port of Moerdijk.

0. Import libraries#

import pathlib

# package(s) used for creating and geo-locating the graph
import networkx as nx
import shapely

# package(s) related to the simulation (creating the vessel, running the simulation)
import datetime
import simpy
import opentnsim
import opentnsim.fis as fis
from opentnsim.core.logutils import logbook2eventtable
from opentnsim.core.plotutils import generate_vessel_gantt_chart

# package(s) needed for inspecting the output
import pandas as pd
import geopandas as gpd
import movingpandas as mpd

# plot libraries
import folium
import pyarrow as pa
from lonboard import Map, PathLayer, viz
from lonboard.colormap import apply_categorical_cmap
from lonboard.experimental import TripsLayer

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", 
    (
        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
    ), 
    {}
)

2. Create graph#

Next we create a network (a graph) along which the vessel can move. For this case we use the Fairway Information System graph, and make the vessel sail from a container terminal at the Maasvlakte to an container terminal at the Port of Moerdijk.

# load the processed version from the Fairway Information System graph provided by Rijkswaterstaat
FG = fis.load_network(version="0.3")
---------------------------------------------------------------------------
KeyboardInterrupt                         Traceback (most recent call last)
Cell In[3], line 2
      1 # load the processed version from the Fairway Information System graph provided by Rijkswaterstaat
----> 2 FG = fis.load_network(version="0.3")

File ~/work/OpenTNSim/OpenTNSim/opentnsim/fis.py:31, in load_network(version)
     23 """load the pickle version of the fairway information system network
     24 
     25 Parameters
   (...)     28     The version of the network to load. Choose 0.2 or 0.3. Default is "0.3".
     29 """
     30 url = urls[version]
---> 31 resp = requests.get(url)
     32 # convert the response to a file
     33 f = io.BytesIO(resp.content)

File /opt/hostedtoolcache/Python/3.13.7/x64/lib/python3.13/site-packages/requests/api.py:73, in get(url, params, **kwargs)
     62 def get(url, params=None, **kwargs):
     63     r"""Sends a GET request.
     64 
     65     :param url: URL for the new :class:`Request` object.
   (...)     70     :rtype: requests.Response
     71     """
---> 73     return request("get", url, params=params, **kwargs)

File /opt/hostedtoolcache/Python/3.13.7/x64/lib/python3.13/site-packages/requests/api.py:59, in request(method, url, **kwargs)
     55 # By using the 'with' statement we are sure the session is closed, thus we
     56 # avoid leaving sockets open which can trigger a ResourceWarning in some
     57 # cases, and look like a memory leak in others.
     58 with sessions.Session() as session:
---> 59     return session.request(method=method, url=url, **kwargs)

File /opt/hostedtoolcache/Python/3.13.7/x64/lib/python3.13/site-packages/requests_cache/session.py:183, in CacheMixin.request(self, method, url, headers, expire_after, only_if_cached, refresh, force_refresh, *args, **kwargs)
    181 headers = set_request_headers(headers, expire_after, only_if_cached, refresh, force_refresh)
    182 with patch_form_boundary() if kwargs.get('files') else nullcontext():
--> 183     return super().request(method, url, *args, headers=headers, **kwargs)

File /opt/hostedtoolcache/Python/3.13.7/x64/lib/python3.13/site-packages/requests/sessions.py:589, in Session.request(self, method, url, params, data, headers, cookies, files, auth, timeout, allow_redirects, proxies, hooks, stream, verify, cert, json)
    584 send_kwargs = {
    585     "timeout": timeout,
    586     "allow_redirects": allow_redirects,
    587 }
    588 send_kwargs.update(settings)
--> 589 resp = self.send(prep, **send_kwargs)
    591 return resp

File /opt/hostedtoolcache/Python/3.13.7/x64/lib/python3.13/site-packages/requests_cache/session.py:230, in CacheMixin.send(self, request, expire_after, only_if_cached, refresh, force_refresh, **kwargs)
    228     response = self._resend(request, actions, cached_response, **kwargs)  # type: ignore
    229 elif actions.send_request:
--> 230     response = self._send_and_cache(request, actions, cached_response, **kwargs)
    231 else:
    232     response = cached_response  # type: ignore  # Guaranteed to be non-None by this point

File /opt/hostedtoolcache/Python/3.13.7/x64/lib/python3.13/site-packages/requests_cache/session.py:254, in CacheMixin._send_and_cache(self, request, actions, cached_response, **kwargs)
    250 """Send a request and cache the response, unless disabled by settings or headers.
    251 If applicable, also handle conditional requests.
    252 """
    253 request = actions.update_request(request)
--> 254 response = super().send(request, **kwargs)
    255 actions.update_from_response(response)
    257 if not actions.skip_write:

File /opt/hostedtoolcache/Python/3.13.7/x64/lib/python3.13/site-packages/requests/sessions.py:724, in Session.send(self, request, **kwargs)
    721 if allow_redirects:
    722     # Redirect resolving generator.
    723     gen = self.resolve_redirects(r, request, **kwargs)
--> 724     history = [resp for resp in gen]
    725 else:
    726     history = []

File /opt/hostedtoolcache/Python/3.13.7/x64/lib/python3.13/site-packages/requests/sessions.py:265, in SessionRedirectMixin.resolve_redirects(self, resp, req, stream, timeout, verify, cert, proxies, yield_requests, **adapter_kwargs)
    263     yield req
    264 else:
--> 265     resp = self.send(
    266         req,
    267         stream=stream,
    268         timeout=timeout,
    269         verify=verify,
    270         cert=cert,
    271         proxies=proxies,
    272         allow_redirects=False,
    273         **adapter_kwargs,
    274     )
    276     extract_cookies_to_jar(self.cookies, prepared_request, resp.raw)
    278     # extract redirect url, if any, for the next loop

File /opt/hostedtoolcache/Python/3.13.7/x64/lib/python3.13/site-packages/requests_cache/session.py:230, in CacheMixin.send(self, request, expire_after, only_if_cached, refresh, force_refresh, **kwargs)
    228     response = self._resend(request, actions, cached_response, **kwargs)  # type: ignore
    229 elif actions.send_request:
--> 230     response = self._send_and_cache(request, actions, cached_response, **kwargs)
    231 else:
    232     response = cached_response  # type: ignore  # Guaranteed to be non-None by this point

File /opt/hostedtoolcache/Python/3.13.7/x64/lib/python3.13/site-packages/requests_cache/session.py:254, in CacheMixin._send_and_cache(self, request, actions, cached_response, **kwargs)
    250 """Send a request and cache the response, unless disabled by settings or headers.
    251 If applicable, also handle conditional requests.
    252 """
    253 request = actions.update_request(request)
--> 254 response = super().send(request, **kwargs)
    255 actions.update_from_response(response)
    257 if not actions.skip_write:

File /opt/hostedtoolcache/Python/3.13.7/x64/lib/python3.13/site-packages/requests/sessions.py:746, in Session.send(self, request, **kwargs)
    743         pass
    745 if not stream:
--> 746     r.content
    748 return r

File /opt/hostedtoolcache/Python/3.13.7/x64/lib/python3.13/site-packages/requests/models.py:902, in Response.content(self)
    900         self._content = None
    901     else:
--> 902         self._content = b"".join(self.iter_content(CONTENT_CHUNK_SIZE)) or b""
    904 self._content_consumed = True
    905 # don't need to release the connection; that's been handled by urllib3
    906 # since we exhausted the data.

File /opt/hostedtoolcache/Python/3.13.7/x64/lib/python3.13/site-packages/requests/models.py:820, in Response.iter_content.<locals>.generate()
    818 if hasattr(self.raw, "stream"):
    819     try:
--> 820         yield from self.raw.stream(chunk_size, decode_content=True)
    821     except ProtocolError as e:
    822         raise ChunkedEncodingError(e)

File /opt/hostedtoolcache/Python/3.13.7/x64/lib/python3.13/site-packages/urllib3/response.py:1091, in HTTPResponse.stream(self, amt, decode_content)
   1089 else:
   1090     while not is_fp_closed(self._fp) or len(self._decoded_buffer) > 0:
-> 1091         data = self.read(amt=amt, decode_content=decode_content)
   1093         if data:
   1094             yield data

File /opt/hostedtoolcache/Python/3.13.7/x64/lib/python3.13/site-packages/urllib3/response.py:980, in HTTPResponse.read(self, amt, decode_content, cache_content)
    977     if len(self._decoded_buffer) >= amt:
    978         return self._decoded_buffer.get(amt)
--> 980 data = self._raw_read(amt)
    982 flush_decoder = amt is None or (amt != 0 and not data)
    984 if not data and len(self._decoded_buffer) == 0:

File /opt/hostedtoolcache/Python/3.13.7/x64/lib/python3.13/site-packages/urllib3/response.py:904, in HTTPResponse._raw_read(self, amt, read1)
    901 fp_closed = getattr(self._fp, "closed", False)
    903 with self._error_catcher():
--> 904     data = self._fp_read(amt, read1=read1) if not fp_closed else b""
    905     if amt is not None and amt != 0 and not data:
    906         # Platform-specific: Buggy versions of Python.
    907         # Close the connection when no data is returned
   (...)    912         # not properly close the connection in all cases. There is
    913         # no harm in redundantly calling close.
    914         self._fp.close()

File /opt/hostedtoolcache/Python/3.13.7/x64/lib/python3.13/site-packages/urllib3/response.py:887, in HTTPResponse._fp_read(self, amt, read1)
    884     return self._fp.read1(amt) if amt is not None else self._fp.read1()
    885 else:
    886     # StringIO doesn't like amt=None
--> 887     return self._fp.read(amt) if amt is not None else self._fp.read()

File /opt/hostedtoolcache/Python/3.13.7/x64/lib/python3.13/http/client.py:479, in HTTPResponse.read(self, amt)
    476 if self.length is not None and amt > self.length:
    477     # clip the read to the "end of response"
    478     amt = self.length
--> 479 s = self.fp.read(amt)
    480 if not s and amt:
    481     # Ideally, we would raise IncompleteRead if the content-length
    482     # wasn't satisfied, but it might break compatibility.
    483     self._close_conn()

File /opt/hostedtoolcache/Python/3.13.7/x64/lib/python3.13/socket.py:719, in SocketIO.readinto(self, b)
    717     raise OSError("cannot read from timed out object")
    718 try:
--> 719     return self._sock.recv_into(b)
    720 except timeout:
    721     self._timeout_occurred = True

File /opt/hostedtoolcache/Python/3.13.7/x64/lib/python3.13/ssl.py:1304, in SSLSocket.recv_into(self, buffer, nbytes, flags)
   1300     if flags != 0:
   1301         raise ValueError(
   1302           "non-zero flags not allowed in calls to recv_into() on %s" %
   1303           self.__class__)
-> 1304     return self.read(nbytes, buffer)
   1305 else:
   1306     return super().recv_into(buffer, nbytes, flags)

File /opt/hostedtoolcache/Python/3.13.7/x64/lib/python3.13/ssl.py:1138, in SSLSocket.read(self, len, buffer)
   1136 try:
   1137     if buffer is not None:
-> 1138         return self._sslobj.read(len, buffer)
   1139     else:
   1140         return self._sslobj.read(len)

KeyboardInterrupt: 
maasvlakte = shapely.Point(4.0566946, 51.9471624)
moerdijk = shapely.Point(4.5944738, 51.6829037)
nodes_gdf = gpd.GeoDataFrame(FG.nodes.values(), index=FG.nodes.keys())
distances, idx = nodes_gdf.sindex.nearest(maasvlakte)
maasvlakte_node = nodes_gdf.iloc[idx[0]]

distances, idx = nodes_gdf.sindex.nearest(moerdijk)
moerdijk_node = nodes_gdf.iloc[idx[0]]
route = nx.shortest_path(FG, maasvlakte_node.name, moerdijk_node.name, weight='length_m')
# Create a map centered between the two points
m = folium.Map(location=[51.83, 4.33], zoom_start = 10)

for edge in FG.edges(data = True):
    points_x = list(edge[2]["geometry"].coords.xy[0])
    points_y = list(edge[2]["geometry"].coords.xy[1])
    
    line = []
    for i, _ in enumerate(points_x):
        line.append((points_y[i], points_x[i]))

    if edge[0] in route and edge[1] in route:
        folium.PolyLine(line, color = "black", weight = 3, popup = edge[2]["Name"]).add_to(m)
    else:
        folium.PolyLine(line, color = "blue", weight = 1, popup = edge[2]["Name"]).add_to(m)


# Add round marker for Maasvlakte with popup
folium.CircleMarker(
    location=[maasvlakte_node.Y, maasvlakte_node.X],
    radius=8,
    color='green',
    fill=True,
    fill_color='green',
    fill_opacity=0.7,
    popup='Maasvlakte'
).add_to(m)

# Add round marker for Moerdijk with popup
folium.CircleMarker(
    location=[moerdijk_node.Y, moerdijk_node.X],
    radius=8,
    color='blue',
    fill=True,
    fill_color='blue',
    fill_opacity=0.7,
    popup='Moerdijk'
).add_to(m)

# Display the map
m
Make this Notebook Trusted to load map: File -> Trust Notebook
FG.nodes['8864054']
{'n': '8864054',
 'X': 3.56379034355024,
 'Y': 51.7453141508194,
 'geometry': <POINT (3.564 51.745)>,
 'Wkt': 'POINT (3.5637903435502398 51.7453141508193966)'}
FG.edges[('8861095', '8864054')]
{'GeoType': 'section',
 'Name': 'Vaarwegvak van 0 tot 2 - H',
 'Length': 2.346,
 'GeneralDepth': nan,
 'GeneralLength': nan,
 'GeneralWidth': nan,
 'SeaFairingDepth': nan,
 'PushedLength': nan,
 'PushedWidth': nan,
 'GeneralHeight': nan,
 'SeaFairingLength': nan,
 'SeaFairingWidth': nan,
 'CoupledLength': nan,
 'CoupledWidth': nan,
 'PushedDepth': nan,
 'WidePushedDepth': nan,
 'CoupledDepth': nan,
 'WidePushedLength': nan,
 'WidePushedWidth': nan,
 'SeaFairingHeight': nan,
 'Id_navigability': nan,
 'Classification': nan,
 'Code': nan,
 'Description': nan,
 'length_deg': nan,
 'length': 0.0255813258548747,
 'Wkt': 'LINESTRING (3.54535894046351 51.727661900382, 3.5602008295784 51.742791566037, 3.56379034355024 51.7453141508194)',
 'StartJunctionId': '8861095',
 'EndJunctionId': '8864054',
 'subgraph': 0,
 'length_m': 2835.225880186762,
 'geometry': <LINESTRING (3.545 51.728, 3.56 51.743, 3.564 51.745)>}

3. Run simulation#

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(2024, 1, 1, 0, 0, 0)
env = simpy.Environment(initial_time=simulation_start.timestamp())
env.epoch = simulation_start

# add graph to environment
env.graph = FG

# create vessel from a dict 
path = route
data_vessel = {
    "env": env,                                       # needed for simpy simulation
    "name": "Vessel",                                 # required by Identifiable
    "geometry": env.graph.nodes[path[0]]['geometry'], # required by Locatable
    "route": path,                                    # required by Routeable
    "v": 1,                                           # required by Movable, 1 m/s to check if the distance is covered in the expected time
}  # 

# create an instance of the Vessel class using the input dict data_vessel
vessel = Vessel(**data_vessel)

# start the simulation
env.process(mission(env, vessel))
env.run()

4. Inspect output#

We can now inspect the simulation output by inspecting the vessel.logbook. Note that the Log mix-in was included when we added Movable. The vessel.logbook keeps track of the moving activities of the vessel. For each discrete event OpenTNSim logs an event message, the start/stop time and the location. The vessel.logbook is of type dict. For convenient inspection it can be loaded into a Pandas dataframe.

# load the logbook data into a dataframe
df = pd.DataFrame.from_dict(vessel.logbook)

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

display(df)
'Vessel' logbook data:
Message Timestamp Value Geometry
0 Sailing from node 8865973 to node 8867633 start 2024-01-01 00:00:00.000000 0.000000 POINT (4.03967999329435 51.9453492703995)
1 Sailing from node 8865973 to node 8867633 stop 2024-01-01 00:44:24.379610 2664.379609 POINT (4.07753550656458 51.9504653911648)
2 Sailing from node 8867633 to node 8862102 start 2024-01-01 00:44:24.379610 2664.379609 POINT (4.07753550656458 51.9504653911648)
3 Sailing from node 8867633 to node 8862102 stop 2024-01-01 00:49:28.777612 2968.777612 POINT (4.08193446085462 51.95077489336861)
4 Sailing from node 8862102 to node 8861217 start 2024-01-01 00:49:28.777612 2968.777612 POINT (4.08193446085462 51.95077489336861)
... ... ... ... ...
83 Sailing from node 8860742 to node 30986654 stop 2024-01-01 16:27:29.435584 59249.435585 POINT (4.58885224011778 51.6948284745577)
84 Sailing from node 30986654 to node 8865570 start 2024-01-01 16:27:29.435584 59249.435585 POINT (4.58885224011778 51.6948284745577)
85 Sailing from node 30986654 to node 8865570 stop 2024-01-01 16:37:38.270048 59858.270049 POINT (4.59124447578966 51.6895622155408)
86 Sailing from node 8865570 to node 8864978 start 2024-01-01 16:37:38.270048 59858.270049 POINT (4.59124447578966 51.6895622155408)
87 Sailing from node 8865570 to node 8864978 stop 2024-01-01 16:57:15.383138 61035.383139 POINT (4.5959831688951 51.6794008186706)

88 rows × 4 columns

The inspection of the logbook data shows that Vessel moved from its origin (Node 0) to its destination (Node 1). The print statements show that the length of the route from Node 0 to Node 1 is exactly 100 km. At a given speed of 1 m/s the trip duration should be exactly 100000 seconds, as is indeed shown to be the case.

df_eventtable = logbook2eventtable([vessel])
df_eventtable.head(3)
object id object name activity name start location stop location start time stop time distance (m) duration (s)
0 034ee50f-a7fe-41dd-86cd-7ffebb1d41a6 Vessel Sailing from node 8865973 to node 8867633 POINT (4.03967999329435 51.9453492703995) POINT (4.07753550656458 51.9504653911648) 2024-01-01 00:00:00.000000 2024-01-01 00:44:24.379610 2664.379609 2664.379610
1 034ee50f-a7fe-41dd-86cd-7ffebb1d41a6 Vessel Sailing from node 8867633 to node 8862102 POINT (4.07753550656458 51.9504653911648) POINT (4.08193446085462 51.95077489336861) 2024-01-01 00:44:24.379610 2024-01-01 00:49:28.777612 304.398002 304.398002
2 034ee50f-a7fe-41dd-86cd-7ffebb1d41a6 Vessel Sailing from node 8862102 to node 8861217 POINT (4.08193446085462 51.95077489336861) POINT (4.07844925662863 51.9415581666513) 2024-01-01 00:49:28.777612 2024-01-01 01:07:01.917087 1053.139475 1053.139475
generate_vessel_gantt_chart(df_eventtable)
duration = df_eventtable['duration (s)'].sum()
distance = df_eventtable['distance (m)'].sum()

print('{} sailed {:.1f} m in {:.1f} s, which is {}'.format(vessel.name, distance, duration, str(datetime.timedelta(seconds=duration))))
Vessel sailed 61035.4 m in 61035.4 s, which is 16:57:15.383138

5. Visualize simulation#

Animation is only of points in the log, resulting in the ship sailing over land.

# Create a geopandas GeoDataFrame from the vessel_log
vessel_log_gdf = gpd.GeoDataFrame(df, geometry=df['Geometry'], crs='EPSG:4326')
# Drop the 'Geometry' column, since it is now a duplicate of the 'geometry' column created by geopandas
vessel_log_gdf = vessel_log_gdf.drop(columns=['Geometry'])
src_dir = pathlib.Path(opentnsim.__file__).parent.parent

# Graph location
location_graph = src_dir / "notebooks"

# Create a Trajectory using movingpandas with the id being the ship name.
traj = mpd.Trajectory(vessel_log_gdf, t='Timestamp', traj_id='ship_1')
# Convert Trajectory to GeoDataFrame to save to a .gpkg file
traj_gdf = traj.to_point_gdf()
traj_gdf.to_file(location_graph / '0006 - route.gpkg')
INFO:pyogrio._io:Created 45 records
# For visualization, the typing of the attributes needs to be consistent. Here we choose to convert them all to string.
traj_gdf['Value'] = traj_gdf['Value'].astype(str)
viz(traj_gdf)
# For the animation we need to create a TrajectoryCollection first. Here we construct it again from the point_gdf
traj_collection = mpd.TrajectoryCollection(traj_gdf, traj_id_col='traj_id', t='t')

# Now we only have one ship, but you can assign different colors to different ships
trajid_to_color = {
    "ship_1": "blue",
}

get_color = apply_categorical_cmap(pa.array(traj_gdf['traj_id'].unique()), trajid_to_color)

# Create the visualization from the TrajectoryCollection
trips_layer = TripsLayer.from_movingpandas(
    traj_collection,
    width_min_pixels=5,
    trail_length=1000,
    get_color=get_color
)
/Users/hemert/projects/tki-openclsim/OpenTNSim/.venv/lib/python3.12/site-packages/lonboard/experimental/traits.py:151: UserWarning:

Reducing precision of input timestamp data to 's' to fit into available GPU precision.
# Show the visualization on the map
m = Map(trips_layer)
m
# Animate the visualization
trips_layer.animate(step=datetime.timedelta(seconds=30), fps=30)