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#
# 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
# plot libraries
import folium
print("This notebook is executed with OpenTNSim version {}".format(opentnsim.__version__))
This notebook is executed with OpenTNSim version 0.1.dev1+gbe28de4fe
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")
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, tiles="cartodb positron")
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 = "red", weight = 3, popup = edge[2]["Name"]).add_to(m)
else:
folium.PolyLine(line, color = "black", weight = 3, popup = edge[2]["Name"]).add_to(m)
for node in FG.nodes(data = True):
point = list(node[1]["geometry"].coords.xy)
folium.CircleMarker(location=[point[1][0],point[0][0]], color='black',fill_color = "black", fill=True, radus = 1, popup = node[0]).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
---------------------------------------------------------------------------
KeyboardInterrupt Traceback (most recent call last)
Cell In[8], line 44
33 folium.CircleMarker(
34 location=[moerdijk_node.Y, moerdijk_node.X],
35 radius=8,
(...) 40 popup='Moerdijk'
41 ).add_to(m)
43 # Display the map
---> 44 m
File /opt/hostedtoolcache/Python/3.13.11/x64/lib/python3.13/site-packages/IPython/core/displayhook.py:279, in DisplayHook.__call__(self, result)
277 self.start_displayhook()
278 self.write_output_prompt()
--> 279 format_dict, md_dict = self.compute_format_data(result)
280 self.update_user_ns(result)
281 self.fill_exec_result(result)
File /opt/hostedtoolcache/Python/3.13.11/x64/lib/python3.13/site-packages/IPython/core/displayhook.py:164, in DisplayHook.compute_format_data(self, result)
134 def compute_format_data(self, result):
135 """Compute format data of the object to be displayed.
136
137 The format data is a generalization of the :func:`repr` of an object.
(...) 162
163 """
--> 164 return self.shell.display_formatter.format(result)
File /opt/hostedtoolcache/Python/3.13.11/x64/lib/python3.13/site-packages/IPython/core/formatters.py:238, in DisplayFormatter.format(self, obj, include, exclude)
236 md = None
237 try:
--> 238 data = formatter(obj)
239 except:
240 # FIXME: log the exception
241 raise
File /opt/hostedtoolcache/Python/3.13.11/x64/lib/python3.13/site-packages/decorator.py:235, in decorate.<locals>.fun(*args, **kw)
233 if not kwsyntax:
234 args, kw = fix(args, kw, sig)
--> 235 return caller(func, *(extras + args), **kw)
File /opt/hostedtoolcache/Python/3.13.11/x64/lib/python3.13/site-packages/IPython/core/formatters.py:282, in catch_format_error(method, self, *args, **kwargs)
280 """show traceback on failed format call"""
281 try:
--> 282 r = method(self, *args, **kwargs)
283 except NotImplementedError:
284 # don't warn on NotImplementedErrors
285 return self._check_return(None, args[0])
File /opt/hostedtoolcache/Python/3.13.11/x64/lib/python3.13/site-packages/IPython/core/formatters.py:406, in BaseFormatter.__call__(self, obj)
404 method = get_real_method(obj, self.print_method)
405 if method is not None:
--> 406 return method()
407 return None
408 else:
File /opt/hostedtoolcache/Python/3.13.11/x64/lib/python3.13/site-packages/folium/folium.py:358, in Map._repr_html_(self, **kwargs)
356 self._parent = None
357 else:
--> 358 out = self._parent._repr_html_(**kwargs)
359 return out
File /opt/hostedtoolcache/Python/3.13.11/x64/lib/python3.13/site-packages/branca/element.py:414, in Figure._repr_html_(self, **kwargs)
412 def _repr_html_(self, **kwargs) -> str:
413 """Displays the Figure in a Jupyter notebook."""
--> 414 html = escape(self.render(**kwargs))
415 if self.height is None:
416 iframe = (
417 '<div style="width:{width};">'
418 '<div style="position:relative;width:100%;height:0;padding-bottom:{ratio};">' # noqa
(...) 424 "</div></div>"
425 ).format(html=html, width=self.width, ratio=self.ratio)
File /opt/hostedtoolcache/Python/3.13.11/x64/lib/python3.13/site-packages/branca/element.py:410, in Figure.render(self, **kwargs)
408 for name, child in self._children.items():
409 child.render(**kwargs)
--> 410 return self._template.render(this=self, kwargs=kwargs)
File /opt/hostedtoolcache/Python/3.13.11/x64/lib/python3.13/site-packages/jinja2/environment.py:1293, in Template.render(self, *args, **kwargs)
1290 ctx = self.new_context(dict(*args, **kwargs))
1292 try:
-> 1293 return self.environment.concat(self.root_render_func(ctx)) # type: ignore
1294 except Exception:
1295 self.environment.handle_exception()
File <template>:24, in root(context, missing, environment)
File /opt/hostedtoolcache/Python/3.13.11/x64/lib/python3.13/site-packages/jinja2/runtime.py:303, in Context.call(_Context__self, _Context__obj, *args, **kwargs)
300 kwargs.pop("_loop_vars", None)
302 try:
--> 303 return __obj(*args, **kwargs)
304 except StopIteration:
305 return __self.environment.undefined(
306 "value was undefined because a callable raised a"
307 " StopIteration exception"
308 )
File /opt/hostedtoolcache/Python/3.13.11/x64/lib/python3.13/site-packages/branca/element.py:208, in Element.render(self, **kwargs)
206 def render(self, **kwargs) -> str:
207 """Renders the HTML representation of the element."""
--> 208 return self._template.render(this=self, kwargs=kwargs)
File /opt/hostedtoolcache/Python/3.13.11/x64/lib/python3.13/site-packages/jinja2/environment.py:1293, in Template.render(self, *args, **kwargs)
1290 ctx = self.new_context(dict(*args, **kwargs))
1292 try:
-> 1293 return self.environment.concat(self.root_render_func(ctx)) # type: ignore
1294 except Exception:
1295 self.environment.handle_exception()
File <template>:17, in root(context, missing, environment)
File /opt/hostedtoolcache/Python/3.13.11/x64/lib/python3.13/site-packages/jinja2/runtime.py:303, in Context.call(_Context__self, _Context__obj, *args, **kwargs)
300 kwargs.pop("_loop_vars", None)
302 try:
--> 303 return __obj(*args, **kwargs)
304 except StopIteration:
305 return __self.environment.undefined(
306 "value was undefined because a callable raised a"
307 " StopIteration exception"
308 )
File /opt/hostedtoolcache/Python/3.13.11/x64/lib/python3.13/site-packages/branca/element.py:208, in Element.render(self, **kwargs)
206 def render(self, **kwargs) -> str:
207 """Renders the HTML representation of the element."""
--> 208 return self._template.render(this=self, kwargs=kwargs)
File /opt/hostedtoolcache/Python/3.13.11/x64/lib/python3.13/site-packages/jinja2/environment.py:1290, in Template.render(self, *args, **kwargs)
1286 import asyncio
1288 return asyncio.run(self.render_async(*args, **kwargs))
-> 1290 ctx = self.new_context(dict(*args, **kwargs))
1292 try:
1293 return self.environment.concat(self.root_render_func(ctx)) # type: ignore
File /opt/hostedtoolcache/Python/3.13.11/x64/lib/python3.13/site-packages/jinja2/environment.py:1388, in Template.new_context(self, vars, shared, locals)
1375 def new_context(
1376 self,
1377 vars: t.Optional[t.Dict[str, t.Any]] = None,
1378 shared: bool = False,
1379 locals: t.Optional[t.Mapping[str, t.Any]] = None,
1380 ) -> Context:
1381 """Create a new :class:`Context` for this template. The vars
1382 provided will be passed to the template. Per default the globals
1383 are added to the context. If shared is set to `True` the data
(...) 1386 `locals` can be a dict of local variables for internal usage.
1387 """
-> 1388 return new_context(
1389 self.environment, self.name, self.blocks, vars, shared, self.globals, locals
1390 )
File /opt/hostedtoolcache/Python/3.13.11/x64/lib/python3.13/site-packages/jinja2/runtime.py:117, in new_context(environment, template_name, blocks, vars, shared, globals, locals)
115 if value is not missing:
116 parent[key] = value
--> 117 return environment.context_class(
118 environment, parent, template_name, blocks, globals=globals
119 )
File /opt/hostedtoolcache/Python/3.13.11/x64/lib/python3.13/site-packages/jinja2/runtime.py:165, in Context.__init__(self, environment, parent, name, blocks, globals)
144 @abc.Mapping.register
145 class Context:
146 """The template context holds the variables of a template. It stores the
147 values passed to the template and also the names the template exports.
148 Creating instances is neither supported nor useful as it's created
(...) 162 :class:`Undefined` object for missing variables.
163 """
--> 165 def __init__(
166 self,
167 environment: "Environment",
168 parent: t.Dict[str, t.Any],
169 name: t.Optional[str],
170 blocks: t.Dict[str, t.Callable[["Context"], t.Iterator[str]]],
171 globals: t.Optional[t.MutableMapping[str, t.Any]] = None,
172 ):
173 self.parent = parent
174 self.vars: t.Dict[str, t.Any] = {}
KeyboardInterrupt:
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 | c5b6a6be-5aed-4179-9dc5-6e0665a79ad5 | 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 | c5b6a6be-5aed-4179-9dc5-6e0665a79ad5 | 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 | c5b6a6be-5aed-4179-9dc5-6e0665a79ad5 | 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