For people not familiar with notebooks, let us simply stress that:
in order to evaluate a code cell, you first select it (click in it), and then press *Shift-Enter* - or, equivalent, click the right arrow button in the menubar. The next cell gets selected, so you can essentially run you way through the document by selecting the first cell, and then pressing *Shift-Enter* until you're done.
you can also run the whole notebook in a single click - although this is not the recommended technique - from the menubar with *Cell → Run All*
There are several ways to run a notebook:
The method that you chose is important, because :
on the one hand, using a public infrastructure removes the burden of having to install anything, so you can start playing around right away;
however, your ssh private key is of course unreachable from mybinder or any other public infrastructure, so as far as actually triggering experiments on R2lab is concerned, you will not have the necessary credentials until you go for the second option. You can still use the visualisation tools though, as some pre-gathered data are part of the git repository.
R2lab's radiomap is a set of measurements that act as a calibration of the testbed. The goal is to measure and visualize received power at all node locations when a radio signal is sent from any given sender node.
Additionally, that same experiment can be carried out with various settings for the emitted signal, like emission power, Tx rate, channel frequency, and with various antenna setups (single antenna, multiple antennas).
End to end experiment involves 2 successive stages:
acquiremap.py
python script, or interactively through the first part of the present notebook; this of course requires at the very least a reservation in the testbed, and as pointed out above is unfortunately not possible from a publicly hosted notebook;For convenience, this git repository also contains a directory datasample
that contains one (partial) dataset obtained by running the first-stage acquisition script, so that visualization can be performed right away, as a way to give a quick sense of the results.
# by default we want to use the pre-shipped data
datadir = 'datasample'
acquiremap.py
¶The àcquiremap.py`python script is designed to expose to the outside 3 levels of scenarios:
a one_run
python function, that runs a complete set of measurements on all nodes, with a specific combination of environment settings (like transmission power, number of antennas, and similar)
a all_runs
python function, that calls one_run
with all possible values for the environment settings
the script itself, when invoked from the command-line, calls all_runs
with the environment settings specified as command-line options.
Additionally, all these functions can be instructed to perform node initializations (load a fresh image on all nodes, and turn off unused nodes). When this feature is turned on with in multiple-runs mode (be it from python or from the shell), nodes initialization is performed only once before the the first invokation of one_run
.
As a side note, we recommend using the following trick:
# for convenience, we use this notebook extension
# that will reload any imported python module
# this is handy if you want to use a tex editor to
# change the code in separate python files while you run the notebook
%load_ext autoreload
%autoreload 2
import nest_asyncio
nest_asyncio.apply()
one_run
¶Back to data acquisition, the one_run
python function performs data collection on a set of nodes (default is all nodes), for one given setting of the environment variables.
from acquiremap import one_run
one_run(wireless_driver, tx_power, phy_rate, antenna_mask, channel, run_name='myradiomap', slicename='inria_admin', load_images=False, node_ids=None, parallel=None, verbose_ssh=False, verbose_jobs=False, dry_run=False)
The one_run
function implements a scenario in which each node sends a number of ping packets to every other node.
In addition, all nodes run a tcpdump process, and at the end of the run,
every pcap file (called fit<N>.pcap
at node N
) is analyzed locally at node N to
retrieve the RSSI values received from each other node on its antenna(s), and the result
is stored in file result-N.txt
(still on node N).
When all nodes are done, the results are fetched from all nodes and centralized on this laptop in a directory called t{}-r{}-a{}-ch{}
containing all the files retrieved from all nodes, i.e., fitN.pcap
, result-N.txt
for all N nodes.
With:
t{}
identifies Tx power for sender nodes in dBm (e.g. 5 to 14):r{}
identifies PHY Tx rate used on all nodes (1 to 54 Mbps)a{}
identifies the antenna mask on all nodesch{}
denotes the WiFi channel used for transmissionAt that point, the post-processing function processmap.py
is invoked
to generate intermediate files rssi-<N>.txt
(one per node),
and eventually one consolidated file RSSI.txt
, that will be used to plot the radiomap.
IMPORTANT NOTE: Both Atheros 93xx (with ath9k
driver) and Intel 5300 (with iwlwifi
driver) a/b/g/n NICs are now supported in these scripts. However, both cards do no have the same features and in particular, the iwlwifi
driver limits the number of wireless stations in the same IBSS (parameter IWLAGN_STATION_COUNT
) to a dozen! So, if you run the script with the 37 nodes, you will observe strange behaviors... Also the Intel 5300 cards are not allowed to use the 5GHz band in Ad Hoc mode. One good point is that it is possible with these cards to decrease the TX power to 0dBm (the lower bound for Atheros cards is 5dBm).
all_runs
¶This function simply calls one_run
with several combinations (a cartesian product) of environment settings.
from acquiremap import all_runs
help(all_runs)
Help on function all_runs in module acquiremap: all_runs(wireless_driver, tx_powers, phy_rates, antenna_masks, channels, *args, **kwds) calls one_run with the cartesian product of tx_powers, phy_rates, antenna_masks and channels, that are expected to be lists of strings All other arguments to one_run may/must be specified as well Example: all_runs([5, 14], [1], [1], [1, 40], ...) will call one_run exactly 4 times
For example, instead of expecting a paramater tx_power
that is a simple string, it expects parameter tx_powers
that is a list of tx_power
strings to consider. So for example
all_runs(tx_powers=[5], phy_rates=[1, 54], antenna_masks=[3, 7])
would result in 4 runs of one_run
with the 2 possible values for phy_rates
multiplied by the 2 possible values for antenna_masks
The command-interface lets you essentially call all_runs
directly from your shell
#!./acquiremap.py --help
So by default this simple shell script will run the scenario with all values for Tx power, PHY Tx rate and Antenna configurations. Book R2lab for at least 2 hours to run it.
Any of the one_run
or all_runs
functions accept a keyword-only parameter named run_name
, that specifies the name of the subdirectory where results will be stored.
It defaults to myradiomap
but you are encouraged
to provide your own in order to isolate your results.
Tweak the following cell to run your own data collection campaign:
# set this to True if you want to run your own data collection experiment
use_my_data = True
# if that is the case you need to set these as well
if use_my_data:
# this plays the same role as 'datasample' -- see below
# but to store your own data
datadir = "mymap-intel"
# enter here the name of the slice for which you have a valid reservation
slicename = 'inria_admin'
# run the data collection
if use_my_data:
all_runs(run_name=datadir, tx_powers=[0,15], phy_rates=[1,54],
antenna_masks=[7], channels=[1], node_ids=[5,7,8,9,10,11,12,19,20,21,22,32,33,34,36],
wireless_driver='iwlwifi', load_images=True, slicename=slicename)
13:47:24.056 SCHEDULER 1D + 0R + 152I = 153: Emergency exit upon exception in critical job
--------------------------------------------------------------------------- TypeError Traceback (most recent call last) /var/folders/6n/2x3z_fxs0pxcyh_g3bkjzfyr0000gn/T/ipykernel_26803/2447732765.py in <module> 3 all_runs(run_name=datadir, tx_powers=[0,15], phy_rates=[1,54], 4 antenna_masks=[7], channels=[1], node_ids=[5,7,8,9,10,11,12,19,20,21,22,32,33,34,36], ----> 5 wireless_driver='iwlwifi', load_images=True, slicename=slicename) ~/fit-r2lab/r2lab-demos/radiomap/acquiremap.py in all_runs(wireless_driver, tx_powers, phy_rates, antenna_masks, channels, *args, **kwds) 355 # record any failure 356 if not one_run(wireless_driver, tx_power, phy_rate, antenna_mask, --> 357 channel, *args, **kwds): 358 overall = False 359 # make sure images will get loaded only once ~/fit-r2lab/r2lab-demos/radiomap/acquiremap.py in one_run(wireless_driver, tx_power, phy_rate, antenna_mask, channel, run_name, slicename, load_images, node_ids, parallel, verbose_ssh, verbose_jobs, dry_run) 312 # if not in dry-run mode, let's proceed to the actual experiment 313 # ok = scheduler.orchestrate(jobs_window=jobs_window) --> 314 ok = scheduler.orchestrate() 315 # give details if it failed 316 if not ok: ~/miniconda3/envs/r2lab-demos/lib/python3.7/site-packages/asynciojobs/purescheduler.py in run(self, *args, **kwds) 750 """ 751 loop = asyncio.get_event_loop() --> 752 return loop.run_until_complete(self.co_run(*args, **kwds)) 753 754 # define the alias for legacy ~/miniconda3/envs/r2lab-demos/lib/python3.7/site-packages/nest_asyncio.py in run_until_complete(self, future) 87 raise RuntimeError( 88 'Event loop stopped before Future completed.') ---> 89 return f.result() 90 91 def _run_once(self): ~/miniconda3/envs/r2lab-demos/lib/python3.7/asyncio/futures.py in result(self) 176 self.__log_traceback = False 177 if self._exception is not None: --> 178 raise self._exception 179 return self._result 180 ~/miniconda3/envs/r2lab-demos/lib/python3.7/asyncio/tasks.py in __step(***failed resolving arguments***) 247 # We use the `send` method directly, because coroutines 248 # don't have `__iter__` and `__next__` methods. --> 249 result = coro.send(None) 250 else: 251 result = coro.throw(exc) ~/miniconda3/envs/r2lab-demos/lib/python3.7/site-packages/asynciojobs/scheduler.py in co_run(self) 132 exc = job.raised_exception() 133 if exc: --> 134 raise exc 135 # we should not reach this point 136 raise ValueError("Internal error in Scheduler.co_run()") ~/miniconda3/envs/r2lab-demos/lib/python3.7/asyncio/tasks.py in __step(***failed resolving arguments***) 247 # We use the `send` method directly, because coroutines 248 # don't have `__iter__` and `__next__` methods. --> 249 result = coro.send(None) 250 else: 251 result = coro.throw(exc) ~/miniconda3/envs/r2lab-demos/lib/python3.7/site-packages/asynciojobs/window.py in wrapped() 36 await self.queue.put(1) 37 job._running = True # pylint: disable=w0212 ---> 38 value = await job.co_run() 39 # release slot in the queue 40 await self.queue.get() ~/miniconda3/envs/r2lab-demos/lib/python3.7/site-packages/apssh/sshjob.py in co_run(self) 218 result = await command.co_run_local(self.node) 219 else: --> 220 result = await command.co_run_remote(self.node) 221 222 # has the command failed ? ~/miniconda3/envs/r2lab-demos/lib/python3.7/site-packages/apssh/commands.py in co_run_remote(self, node) 234 self._verbose_message(node, "Run: -> {}".format(command)) 235 # need an ssh connection --> 236 connected = await node.connect_lazy() 237 if not connected: 238 return ~/miniconda3/envs/r2lab-demos/lib/python3.7/site-packages/apssh/sshproxy.py in connect_lazy(self) 252 async with self._connect_lock: 253 if self.conn is None: --> 254 await self._connect() 255 return self.conn 256 ~/miniconda3/envs/r2lab-demos/lib/python3.7/site-packages/apssh/sshproxy.py in _connect(self) 261 if self.gateway: 262 return await self._connect_tunnel() --> 263 return await self._connect_direct() 264 265 async def _connect_direct(self): ~/miniconda3/envs/r2lab-demos/lib/python3.7/site-packages/apssh/sshproxy.py in _connect_direct(self) 289 config=None, 290 ), --> 291 timeout=self.timeout) 292 293 async def _connect_tunnel(self): ~/miniconda3/envs/r2lab-demos/lib/python3.7/asyncio/tasks.py in wait_for(fut, timeout, loop) 440 441 if fut.done(): --> 442 return fut.result() 443 else: 444 fut.remove_done_callback(cb) ~/miniconda3/envs/r2lab-demos/lib/python3.7/asyncio/futures.py in result(self) 176 self.__log_traceback = False 177 if self._exception is not None: --> 178 raise self._exception 179 return self._result 180 ~/miniconda3/envs/r2lab-demos/lib/python3.7/asyncio/tasks.py in __step(***failed resolving arguments***) 247 # We use the `send` method directly, because coroutines 248 # don't have `__iter__` and `__next__` methods. --> 249 result = coro.send(None) 250 else: 251 result = coro.throw(exc) ~/miniconda3/envs/r2lab-demos/lib/python3.7/site-packages/asyncssh/connection.py in create_connection(client_factory, host, port, **kwargs) 5893 """ 5894 -> 5895 conn = await connect(host, port, client_factory=client_factory, **kwargs) 5896 5897 return conn, conn.get_owner() ~/miniconda3/envs/r2lab-demos/lib/python3.7/site-packages/asyncssh/connection.py in connect(host, port, tunnel, family, flags, local_addr, options, **kwargs) 5632 5633 if not options or kwargs: -> 5634 options = SSHClientConnectionOptions(options, **kwargs) 5635 5636 return await _connect(host, port, loop, tunnel, family, flags, local_addr, ~/miniconda3/envs/r2lab-demos/lib/python3.7/site-packages/asyncssh/misc.py in __init__(self, options, **kwargs) 210 211 self.kwargs.update(kwargs) --> 212 self.prepare(**self.kwargs) 213 214 def prepare(self): TypeError: prepare() got an unexpected keyword argument 'config'
The git repository comes with a pre-populated dataset collected by us, in the datasample
directory. This contains all the RSSI information to run this visualization.
If you have collected RSSI data, you will be able to
The datasample
directory contains a collection of RSSI.txt
files in the following subdirectories:
Where
t5
means an emission power of 5dBm
r1
means PHY rate=1Mbps
a1
means 1 single valid antenna - and so 2 values in RSSI.txt
a3
means 2 valid antennas - and so 3 values in RSSI.txt
a7
means 3 valid antennas - and so 4 value in RSSI.txt
ch1
means channel 1, i.e. 2412 MHz frequency
This naming scheme is implemented in a helper function in acquiremap
:
from acquiremap import naming_scheme
# here's what the naming scheme looks like
naming_scheme('example-run', tx_power=5, phy_rate=1, antenna_mask=3, channel=40)
# interactive_output is used to refresh
# a visualization based on user input
from ipywidgets import interactive_output, fixed
# plotly is one visualization library
import plotly
import plotly.plotly as py
import plotly.graph_objs as go
# using plotly in offline mode is a requirement
# in interactive mode - too slow otherwise
import plotly.offline as pyoff
pyoff.init_notebook_mode()
In order to leave low-details out of the notebook, we have chosen to ship such small matters in separate files as python modules, right in this directory.
# to extract data from a RSSI file
from rssi import read_rssi
### R2lab common utilities
# a mapping node -> gridx, gridy
from r2lab import R2labMap
# how to initialize a dataframe
from r2lab import MapDataFrame
# this module deals with gory details of ipywidgets
# its only purpose is to to come up with some
# reasonably compact layout for our control buttons
from dashboard import dashboard
bokeh
heatmaps¶With this in place, we can define a first visualization angle that relies on a 2D representation using bokeh
.
This approach gives IMHO better results than with plotly
- that we will see next - as we are able to alter the figure when a change is made via the dashboard, instead of having to redraw it, which leads to a flickering effect.
# just in case we want to start running from here
try:
# is this variable defined ?
datadir
except:
# if not let's use the data that ships with the git repo
datadir = 'datasample'
# again just in case
from ipywidgets import interactive_output, fixed
from dashboard import dashboard
from acquiremap import naming_scheme
from rssi import read_rssi
from bokeh.plotting import figure, show
from bokeh.io import output_notebook, push_notebook
from bokeh.models import ColorBar, ColumnDataSource, LinearColorMapper
output_notebook()
# convert data into x, y and z
from rssi import rssi_to_heatmap
# from https://bokeh.pydata.org/en/latest/docs/gallery/unemployment.html
# this is the colormap from the original NYTimes plot
#
# xxx this colormap does not reflect our use case very well and needs more work
# 0 means very strong power, while -100 means we can hardly notice the signal
#
colors = ["#75968f", "#a5bab7", "#c9d9d3", "#e2e2e2", "#dfccce", "#ddb7b1", "#cc7878", "#933b41", "#550b1d"]
colormapper = LinearColorMapper(palette=colors, low=-100, high=0.)
# one global static object is good enough
r2labmap = R2labMap()
def init_bokeh():
df = MapDataFrame(r2labmap, {'rssi': 0.})
cds = ColumnDataSource(df)
tools = "hover,save,pan,box_zoom,reset,wheel_zoom"
fig = figure(
title = 'bokeh-based R2lab radiomap',
plot_width = 900, plot_height=500,
tools = tools, toolbar_location = 'right'
)
# create the rectangles that make the heatmap
fig.rect(x='x', y='y', width=1, height=1,
fill_color={'field':'rssi',
'transform':colormapper,
},
source = cds)
# show the figure, return handle for updates
handle = show(fig, notebook_handle=True)
# return as a tuple:
# * dataframe is where animation will publish updates
# * handle is an internal bokeh object needed to actually
# redisplay the changes
return df, cds, handle
def update_bokeh(datadir, sender, power, rate, antenna_mask, channel, rssi_rank):
global dataframe, cds, handle
# locate corresponding data file
filename = str(naming_scheme(datadir, power, rate, antenna_mask, channel) / "RSSI.txt")
# read that file
rssi_dict = read_rssi(filename, sender, rssi_rank)
if not rssi_dict:
return
for node_id, rssi in rssi_dict.items():
dataframe.loc[node_id]['rssi'] = rssi
cds.data = cds.from_df(dataframe)
push_notebook(handle)
# create data and figure
dataframe, cds, handle = init_bokeh()
# interactively update it with an UI
interactive_output(update_bokeh, dashboard(datadir, continuous_sender=True))
# debug
# dataframe
plotly
heatmaps¶Using plotly
tends to a simpler code, at the cost of a less pleasant visual effect when the dashboard is used to tweak an experimental setting.
Here we leverage plotly
's Heatmap
tool:
def radiomap2D(datadir, sender, power, rate, antenna_mask, channel, rssi_rank):
# locate corresponding data file
filename = str(naming_scheme(datadir, power, rate, antenna_mask, channel) / "RSSI.txt")
# read that file
rssi_dict = read_rssi(filename, sender, rssi_rank)
if not rssi_dict:
return
X, Y, Z, T = rssi_to_heatmap(rssi_dict)
heatmap = go.Heatmap( x=X, y=Y, z=Z, text=T,
zmin=-100, zmax=0, zauto=False, opacity=1)
axis = [heatmap]
layout = go.Layout(
title="R2lab Radio-Map: Rx power (in dBm) when fit{:02d} is transmitting<br>from {}"
.format(sender, filename))
figure = go.Figure(data = axis, layout=layout)
pyoff.iplot(figure)
NOTE: you need to play with at least one control for the figure to actually show up
# interactively call radiomap2D
interactive_output(radiomap2D, dashboard(datadir))
import ipyvolume as ipv
from rssi import rssi_to_3d
# colors: see https://ipyvolume.readthedocs.io/en/latest/mesh.html#Colors
from matplotlib import cm
def colormap(Z):
colormap = cm.coolwarm
znorm = Z - Z.min()
znorm /= znorm.ptp()
znorm.min(), znorm.max()
color = colormap(znorm)
# just doing this magic as-is from the above link
return color[...,:3]
def radiomap3Dipv(datadir, sender, power, rate, antenna_mask, channel, rssi_rank):
# locate corresponding data file
filename = str(naming_scheme(datadir, power, rate, antenna_mask, channel) / "RSSI.txt")
# read that file
rssi_dict = read_rssi(filename, sender, rssi_rank)
if not rssi_dict:
return
X, Y, Z, T = rssi_to_3d(rssi_dict)
ipv.figure()
ipv.plot_surface(X, Y, Z, color=colormap(Z))
ipv.show()
interactive_output(radiomap3Dipv, dashboard(datadir))
Alternatively, we could use a 3D view based on plotly
to explore the same results; here is what you would see if you used plotly's Surface object instead.
def radiomap3D(datadir, sender, power, rate, antenna_mask, channel, rssi_rank):
# locate corresponding data file
filename = str(naming_scheme(datadir, power, rate, antenna_mask, channel) / "RSSI.txt")
# read that file
rssi_dict = read_rssi(filename, sender, rssi_rank)
if not rssi_dict:
return
X, Y, Z, T = rssi_to_3d(rssi_dict)
surface = go.Surface(x=X, y=Y, z=Z, text=T, zmin=-100, zmax=0,
#connectgaps=False,
)
data = go.Data([surface])
axis = dict(
showbackground=True, # show axis background
backgroundcolor="rgb(204, 204, 204)", # set background color to grey
gridcolor="rgb(255, 255, 255)", # set grid line color
zerolinecolor="rgb(255, 255, 255)", # set zero grid line color
)
title = "R2lab Radio-Map: Rx power (in dBm)"\
"when fit{:02d} is transmitting<br>from {}"\
.format(sender, filename)
layout = go.Layout(
autosize=True,
title = title,
scene=go.Scene(
xaxis=go.XAxis(axis), # set x-axis style
yaxis=go.YAxis(axis), # set y-axis style
zaxis=go.ZAxis(axis, title="RSSI (dBm) ") # set z-axis style
)
)
figure = go.Figure(data=data, layout=layout)
pyoff.iplot(figure)
NOTE: again, you need to play with at least one control for the figure to actually show.
NOTE2: somehow this seems to have broken recently, it looks like Surface
won't accept the zmin
and zmax
parameters any more..
# interactively call radiomap3D
interactive_output(radiomap3D, dashboard(datadir))
Of course this experiment has no scientific value in itself. On the contrary it has been chosen on purpose to be as straightforward as possible, so that we can exclusively focus on the matter of reproducibility.
Hopefully we have illustrated a possible path for structuring research artifacts, with the objective of maximizing reproducibility, at least in the context of using the R2lab testbed.
Additionally, the acquiremap.py
script showcases a real-scale use of nepi-ng
for orchestrating this sort of experimentation.