NS-3-MANUAL(1)

📦 version 3.35, in ubuntu jammy

✨ NAME ✨

Okay bestie, first things first: ns-3-manual is the ns-3 Manual. 💖

This is the ns-3 Manual. Primary documentation for the ns-3 project is available in five forms:

  • ns-3 Doxygen: Documentation of the public APIs of the simulator
  • Tutorial, Manual (this document), and Model Library for the latest release and development tree
  • ns-3 wiki

This document is written in reStructuredText for Sphinx and is maintained in the doc/manual directory of ns-3’s source code. 📚✨


🦋 ORGANIZATION 🦋

This chapter describes the overall ns-3 software organization and the corresponding organization of this manual. 🫶

ns-3 is a discrete-event network simulator in which the simulation core and models are implemented in C++. ns-3 is built as a library which may be statically or dynamically linked to a C++ main program that defines the simulation topology and starts the simulator. ns-3 also exports nearly all of its API to Python, allowing Python programs to import an “ns3” module in much the same way as the ns-3 library is linked by executables in C++. 💅

[image] Software organization of ns-3.UNINDENT

The source code for ns-3 is mostly organized in the src directory and can be described by the diagram in Software organization of ns-3. We will work our way from the bottom up; in general, modules only have dependencies on modules beneath them in the figure. 🌈

We first describe the core of the simulator; those components that are common across all protocol, hardware, and environmental models. The simulation core is implemented in src/core. Packets are fundamental objects in a network simulator and are implemented in src/network. These two simulation modules by themselves are intended to comprise a generic simulation core that can be used by different kinds of networks, not just Internet-based networks. The above modules of ns-3 are independent of specific network and device models, which are covered in subsequent parts of this manual. 👑

In addition to the above ns-3 core, we introduce, also in the initial portion of the manual, two other modules that supplement the core C++-based API. ns-3 programs may access all of the API directly or may make use of a so-called helper API that provides convenient wrappers or encapsulation of low-level API calls. The fact that ns-3 programs can be written to two APIs (or a combination thereof) is a fundamental aspect of the simulator. We also describe how Python is supported in ns-3 before moving onto specific models of relevance to network simulation. 🐍✨

The remainder of the manual is focused on documenting the models and supporting capabilities. The next part focuses on two fundamental objects in ns-3: the Node and NetDevice. Two special NetDevice types are designed to support network emulation use cases, and emulation is described next. The following chapter is devoted to Internet-related models, including the sockets API used by Internet applications. The next chapter covers applications, and the following chapter describes additional support for simulation, such as animators and statistics. 💀📈

The project maintains a separate manual devoted to testing and validation of ns-3 code (see the ns-3 Testing and Validation manual). 🧚


💅 WORKING WITH GIT AS A USER 💅

The ns-3 project used Mercurial in the past as its source code control system, but it has moved to Git in December 2018. Git is a VCS like Mercurial, Subversion or CVS, and it is used to maintain many open-source (and closed-source) projects. While git and mercurial have a lot of common properties, if you are new to git you should read first an introduction to it. The most up-to-date guide is the Git Book, at https://git-scm.com/book/en/v2/Getting-Started-Git-Basics. 📖

The ns-3 project is officially hosted on GitLab.com at https://gitlab.com/nsnam/. For convenience and historical reasons, ns-3-dev mirrors are currently posted on Bitbucket.com and GitHub.com, and kept in sync with the official repository periodically via cron jobs. We recommend that users who have been working from one of these mirrors repoint their remotes so that they pull origin or upstream from GitLab.com (see below explanation about how to configure remotes). 🔁

This section of the manual provides common tips for both users and maintainers. Since the first part is shared, in this manual section we will start with a personal repository and then explain what to do in some typical cases. ns-3 users often combine ns-3-dev with other repositories (pybindgen, netanim, apps from the app store). This manual chapter does not cover this use case; it only focuses on the single ns-3-dev repository. See other project documentation such as the ns-3 tutorial for descriptions on bundled releases distributed as source archives, or on the bake build tool for managing multiple repositories. The guidelines listed below also largely pertain to the user who is using (and cloning) bake from the GitLab.com repository. 🎀

🎀 ns-3’s Git workflow in a nutshell 🎀

Experienced git users will not necessarily need instruction on how to set up personal repositories (below). However, they should be aware of the project’s workflow:

  • The main repository’s master branch is the main development branch. The project maintains only this one branch and strives to maintain a mostly linear history on it.
  • Releases are made by creating a branch from the master branch and tagging the branch with the release number when ready, and then merging the release branch back to the master branch. Releases can be identified by a git tag, and a modified VERSION file in the branch. However, the modified VERSION file is not merged back to master.
    • If a hotfix release must be made to update a past release, a new hotfix support branch will be created by branching from the tip of the last relevant release. Changesets from master branch (such as bug fixes) may be cherry-picked to the hotfix branch. The hotfix release is tagged with the hotfix version number, and merged back to the master branch.
  • Merges to the ns-3 master branch are fast forwarded when possible, and commits can be squashed as appropriate, to maintain a clean linear history. Merge commits can be avoided in simple cases.
    • More complicated merges might not be able to be fast forwarded, with the result that there will be a merge commit upon the merge.
  • Maintainers can commit obvious non-critical fixes (documentation improvements, typos etc.) directly into the master branch. Users who are not maintainers can create GitLab.com Merge Requests for small items such as these, for maintainers to review.
  • Maintainers can directly commit bug fixes to their maintained modules without review/approval by other maintainers, although a review phase is recommended for non-trivial fixes. Larger commits that touch multiple modules should be reviewed and approved by the set of affected maintainers.
  • When proposing code (new features, bug fixes, etc.) for a module maintained by someone else, the typical workflow will be to fork the nsnam/ns-3-dev.git repository, create a local feature branch on your fork, and use GitLab.com to generate a Merge Request towards nsnam/ns-3-dev.git when ready. The Merge Request will then be reviewed, and in response to changes requested or comments from maintainers, authors are are asked to modify their feature branch and rebase to the tip of ns-3-dev.git as needed.

🌸 Setup of a personal repository 🌸

We will provide two ways, one anonymous (but will impede the creation of merge requests) and the other, preferred, that include forking the repository through the GitLab.com web interface.

⭐ Directly cloning ns-3-dev ⭐

If you go to the official ns-3-dev page, hosted at https://gitlab.com/nsnam/ns-3-dev, you can find a button that says Clone. If you are not logged in, then you will see only the option of cloning the repository through HTTPS, with this command:

$ git clone https://gitlab.com/nsnam/ns-3-dev.git

If this command exits successfully, you will have a newly created ns-3-dev directory with all the source code. 🎉

🍓 Forking ns-3-dev on GitLab.com 🍓

Assume that you are the user john on GitLab.com and that you want to create a new repository that is synced with nsnam/ns-3-dev.

  1. Log into GitLab.com
  2. Navigate to https://gitlab.com/nsnam/ns-3-dev
  3. In the top-right corner of the page, click Fork.

Note that you may only do this once; if you try to fork again, Gitlab will take you to the page of the original fork. So, if you are planning to maintain two or more separate forks (for example, one for your private work, another for maintenance, etc.), you are doing a mistake. Instead, you should add these forks as a remote of your existing directory (see below for adding remotes). Usually, it is a good thing to add the maintainer’s repository as remotes, because it can happen that “bleeding edge” features will appear there before landing in ns-3-dev. 👀

For more information on forking with Gilab, there is plenty of visual documentation (‐ https://docs.gitlab.com/ee/gitlab-basics/fork-project.html). To work with your forked repository, you have two ways: one is a clean clone while the other is meant to re-use an existing ns-3 git repository. 🧚

💜 Clone your forked repository on your machine 💜

Git is a distributed versioning system. This means that nobody will touch your personal repository, until you do something. Please note that every gitlab user has, at least, two repositories: the first is represented by the repository hosted on gitlab servers, which will be called in the following origin. Then, you have your clone on your machine. This means that you could have many clones, on different machines, which points to origin. 💻✨

To clone the newly created fork to your system, go to the homepage of your fork (that should be in the form https://gitlab.com/your-user-name/ns-3-dev) and click the Clone button. Then, go to your computer’s terminal, and issue the command (please refer to https://docs.gitlab.com/ee/gitlab-basics/command-line-commands.html#clone-your-project for more documentation):

$ git clone https://gitlab.com/your-user-name/ns-3-dev
$ cd ns-3-dev

In this example we used the HTTPS address because in some place the git + ssh address is blocked by firewalls. If you are not under this constraint, it is recommended to use the git + ssh address to avoid the username/password typing at each request. 🔥

Naming conventions 🧚

okay so bestie, git can fetch and push changes to several repositories, and each one is called a remote. 💖 As time goes on you'll probably have a whole collection of remotes, each with a bunch of branches. To avoid absolute chaos, it's highly recommended to give them meaningful names. In this doc we're gonna use origin for the ns-3-dev repo in your personal namespace (aka your forked version, server-side) and nsnam for the ns-3-dev repo in the nsnam namespace, server-side. periodt. 💅


Add the official ns-3 repository as remote upstream 🌸

Maybe you've already used git before and already have an ns-3 git repo chilling somewhere. Or maybe you just cloned it for the first time in the step above. Either way: when you fork/clone a repo, your history is no longer bound to the original repo itself. So now it's literally your duty to sync your fork with the original. The first remote we met was origin; we gotta add the official ns-3 repo as another remote:

$ git remote add nsnam https://gitlab.com/nsnam/ns-3-dev

With that command we added a remote named nsnam that links to the official ns-3 repo. To show your remote repositories:

$ git remote show

To see what origin is linking to:

$ git remote show origin

Many options are available; please refer to the git manual for more. 💅


Add your forked repository as remote 🎀

If you were a user of the old github mirror, you probably have an existing git repository installed somewhere. In that case, you don't need to clone your fork and port all your work to a new directory; you can just add the fork as a new remote:

$ git remote rename origin old-origin
$ git remote add origin https://gitlab.com/your-user-name/ns-3-dev

After these two commands, you'll have a remote named origin that points to your forked repository on gitlab. slay. ✨


Keep in sync your repository with latest ns-3-dev updates 🔄

We assume, from now to the end of this document, that you will not make commits on top of the master branch. It should be kept clean from any personal modifications: all the work must be done in branches. So to move the current HEAD of the master branch to the latest commit in ns-3-dev, you should do:

$ git checkout master
$ git fetch nsnam
$ git pull nsnam master

If you tried a pull which resulted in a conflict and you'd like to start over, you can recover with git reset (but this never happens if you do not commit over master). 😌


Start a new branch to do some work 🌈

Look at the available branches:

$ git branch -a

you should see something like:

* master
  remotes/origin/master
  remotes/nsnam/master

The branch master is your local master branch; remotes/origin/master points at the master branch on your repository located in the Gitlab server, while remotes/nsnam/master points to the official master branch.

Before we get into the details on how to create a new branch, we have to explain why it's recommended to do it. First of all, if you put all your work in a separate branch, you can easily see the diff between ns-3 mainline and your feature branch (with git diff master). Also, you can integrate upstream advancements into your work more easily, and when you wish, you can create a conflict-free merge request, which will ease the maintainer's job in reviewing your work. 🫶

To create a new branch, starting from master, the command is:

$ git checkout master
$ git checkout -b [name_of_your_new_branch]

To switch between branches, remove the -b option. You should now see:

$ git branch -a
 * master
  [name_of_your_new_branch]
  remotes/origin/master
  remotes/nsnam/master

Edit and commit the modifications 💅

After you edit some file, you should commit the difference. As a policy, git users love small and incremental patches. So, commit early, and commit often: you could rewrite your history later. 💖

Suppose we edited src/internet/model/tcp-socket-base.cc. With git status, we can see the repository status:

$ git status
   On branch tcp-next
   Your branch is up-to-date with 'mirror/tcp-next'.
   Changes not staged for commit:
     modified:   src/internet/model/tcp-socket-base.cc

and we can see the edits with git diff:

$ git diff

nat@miyamoto ~/Work/ns-3-dev-git (tcp-next)$ git diff
diff --git i/src/internet/model/tcp-socket-base.cc w/src/internet/model/tcp-socket-base.cc
index 1bf0f69..e2298b0 100644
--- i/src/internet/model/tcp-socket-base.cc
+++ w/src/internet/model/tcp-socket-base.cc
@@ -1439,6 +1439,10 @@ TcpSocketBase::ReceivedAck (Ptr<Packet> packet, const TcpHeader& tcpHeader)
       // There is a DupAck
       ++m_dupAckCount;

+      // I'm introducing a subtle bug!
+
+      m_tcb->m_cWnd = m_tcb->m_ssThresh;
+
       if (m_tcb->m_congState == TcpSocketState::CA_OPEN)
         {
           // From Open we go Disorder

To create a commit, select the file you want to add to the commit with git add:

$ git add src/internet/model/tcp-socket-base.cc

and then commit the result:

$ git commit -m "My new TCP broken"

Of course, it would be better to have some rules for the commit message: they will be reported in the next subsection. 😭


Commit message guidelines 📝

The commit title should not go over the 80 char limit. It should be prefixed by the name of the module you are working on, and if it fixes a bug, it should reference it in the commit title. For instance, a good commit title would be:

tcp: My new TCP broken

Another example is:

tcp: (fixes #2322) Corrected the uint32_t wraparound during recovery

In the body message, try to explain what the problem was, and how you resolved that. If it is a new feature, try to describe it at a very high level, and highlight any modifications that changed the behaviour or the interface towards the users or other modules. 🔥


Commit log 📜

You can see the history of the commits with git log. To show a particular commit, copy the sha-id and use git show <sha-id>. The ID is unique, so it can be referenced in emails or in issues. The next step is useful if you plan to contribute back your changes, but also to keep your feature branch updated with the latest changes from ns-3-dev. 💜


Rebase your branch on top of master 🧚

Meanwhile you were busy with your branch, the upstream master could have changed. To rebase your work with the now new master, first of all sync your master branch (pulling the nsnam/master branch into your local master branch) as explained before; then

$ git checkout [name_of_your_new_branch]
$ git rebase master

The last command will rewind your work, update the HEAD of your branch to the actual master, and then re-apply all your work. If some of your work conflicts with the actual master, you will be asked to fix these conflicts if automatic merge fails. 😬


Pushing your changes to origin 🚀

After you have done some work on a branch, if you would like to share it with others, there is nothing better than pushing your work to your origin repository, on Gitlab servers.

$ git checkout [name_of_your_new_branch]
$ git push origin [name_of_your_new_branch]

The git push command can be used every time you need to push something from your computer to a remote repository, except when you propose changes to the main ns-3-dev repository: your changes must pass a review stage. 👑

Please note that for older git version, the push command looks like:

$ git push -u origin [name_of_your_new_branch]

Submit work for review 🔍

After you push your branch to origin, you can follow the instructions here https://docs.gitlab.com/ee/gitlab-basics/add-merge-request.html to create a merge request. Please remember to add, as reviewer, at least one maintainer. To get the information on who is maintaining what, please refer to the nsnam website. 🫶


Porting patches from mercurial repositories to git 🧳

Placeholder section; please improve it.


💖 WORKING WITH GIT AS A MAINTAINER 💖

ok so bestie, a maintainer is someone with write access to the main nsnam repo 👑 like you're literally in the building. You can push your own work (no code review needed, main character privileges) or push somebody else's work. Let's get into both cases fr fr.


🫶 Pushing your own work

Since you've been added to the Developer list on Gitlab (and if you're not, pls pls pls open an issue — don't just vibe in the void), you can use the git + ssh address when adding nsnam as remote ✨

Once that's done: make your modifications on a local branch, then update master to point at the latest changes of the nsnam repo, and then:

$ git checkout master
$ git pull nsnam master
$ git merge [your_branch_name]
$ git push nsnam master

heads up bestie: if you wanna keep track of your branch, you can use git merge --no-ff [your_branch_name] instead 🎀 And it's always recommended to rebase your branch before merging, so you get a clean history ✨ That said? Not a requirement. git handles a master with parallel merged branches perfectly fine, no cap.


🦋 Review and merge someone else's work

Gitlab.com has plenty of documentation on how to handle merge requests — go look here:

https://docs.gitlab.com/ee/user/project/merge_requests/index.html

If you're committing a patch from someone else and it's not coming through a Merge Request process, you can use the –author=’’ argument to git commit to assign authorship to another email address 💅 (this is literally what we used to do back in the day with the Mercurial -u option).


🌈 Making a release

So like, as stated above, the project has adopted a workflow to aim for a mostly linear history on a single master branch. Releases are branches from this master branch, but the branches themselves are not long-lived — the release branches get merged back to master in a special way 👀

That said, the release branches can be checked out using the git tag facility. A named release like ns-3.30 can be checked out on a branch by specifying the release name ns-3.30 (or ns-3.30.1 etc.) 🌸

A compact way to represent a git history is this command:

$ git log --graph --decorate --oneline --all

At the point just before the ns-3.34 release, the log looked like this:

* 9df8ef4 (HEAD -> master) doc: Update ns-3 version in tutorial examples
* 9319cdd (origin/master, origin/HEAD) Update CHANGES.html and RELEASE_NOTES
* 8da68b5 wifi: Fix typo in channel access manager test

We want the release to create a small branch that gets merged (in a special way) back to the mainline, yielding something like this:

* 4b27025 (master) Update release files to start next release
*   fd075f6 Merge ns-3.34-release branch
|\
| * 3fab3cf (HEAD, tag: ns-3.34) Update availability in RELEASE_NOTES
| * c50aaf7 Update VERSION and documentation tags for ns-3.34 release
|/
* 9df8ef4 doc: Update ns-3 version in tutorial examples
* 9319cdd (origin/master, origin/HEAD) Update CHANGES.html and RELEASE_NOTES

The first commit on the release branch changes the 3-dev string in VERSION and the various documentation conf.py files to 3.34 ⭐ The second commit on the release branch updates RELEASE_NOTES to state the URL of the release.

Starting with commit 9df8ef4, the following steps were taken to create the ns-3.34 release. First, this commit hash 9df8ef4 will be used later in the merge process 👑

First, create a new release branch locally:

$ git checkout -b 'ns-3.34-release'
Switched to a new branch 'ns-3.34-release'

We change the VERSION field from 3-dev to 3.34:

$ sed -i 's/3-dev/3.34/g' VERSION
$ cat VERSION
3.34

We next change the file conf.py in the tutorial, manual, and models directories to change the strings ns-3-dev to ns-3.34 💅

When you're done, the git status command should show:

VERSION                     | 2 +-
doc/manual/source/conf.py   | 4 ++--
doc/models/source/conf.py   | 4 ++--
doc/tutorial/source/conf.py | 4 ++--

Make a commit of these files:

$ git commit -a -m”Update VERSION and documentation tags for ns-3.34
release”

Next, make the following change to RELEASE_NOTES and commit it:

-This release is not yet available.  +This release is available
from: +https://www.nsnam.org/release/ns-allinone-3.34.tar.bz2

$ git commit -m”Update availability in RELEASE_NOTES”
RELEASE_NOTES

Finally, add a git annotated tag:

$ git tag -a 'ns-3.34' -m"ns-3.34 release"

Now let's merge back to master 🎀 BUT — we want to avoid touching the VERSION and conf.py files on master; what we want is the RELEASE_NOTES change and the new tag. We can accomplish this with a special merge, like so:

$ git checkout master
$ git merge --no-commit --no-ff ns-3.34-release
Automatic merge went well; stopped before committing as requested

Now we wanna reset VERSION to the previous string, which existed before we branched 🌸 We can use git reset on this file and then finish the merge. Recall its commit hash of 9df8ef4 from above.

$ git reset 9df8ef4 VERSION
Unstaged changes after reset:
M     VERSION
$ sed -i 's/3.34/3-dev/g' VERSION
$ cat VERSION
3-dev

Repeat the above resets and change back to 3-dev for each conf.py file ✨

Finally, commit the branch and delete our local release branch:

$ git commit -m"Merge ns-3.34-release branch"
$ git branch -d ns-3.34-release

The git history now looks like this:

$ git log --graph --decorate --oneline --all
*   fd075f6 (HEAD -> master) Merge ns-3.34-release branch
|\
| * 3fab3cf (HEAD, tag: ns-3.34) Update availability in RELEASE_NOTES
| * c50aaf7 Update VERSION and documentation tags for ns-3.34 release
|/
* 9df8ef4 doc: Update ns-3 version in tutorial examples
* 9319cdd (origin/master, origin/HEAD) Update CHANGES.html and RELEASE_NOTES

This may now be pushed to nsnam/ns-3-dev.git and development can continue 🦋

Important: when pushing to the remote, do NOT forget to push the tags:
$ git push --follow-tags

Future users who want to check out the ns-3.34 release will do something like:

$ git checkout -b my-local-ns-3.34 ns-3.34
Switched to a new branch 'my-local-ns-3.34'
Note: it's a good idea to avoid naming the new branch the same as the tag name — in this case, ns-3.34 💅

Let's assume now that master evolves with new features and bugfixes. They're committed to master on nsnam/ns-3-dev.git as usual:

$ git checkout master
... (some changes)
$ git commit -m"make some changes" -a
$ echo 'd' >> d
$ git add d
$ git commit -m"Add new feature" d
... (some more changes)
$ git commit -m"some more changes" -a
... (now fix a really important bug)
$ echo 'abc' >> a
$ git commit -m"Fix missing abc bug on file a" a

Now the tree looks like this:

$ git log --graph --decorate --oneline --all
* ee37d41 (HEAD -> master) Fix missing abc bug on file a
* 9a3432a some more changes
* ba28d6d Add new feature
* e50015a make some changes
*   fd075f6 Merge ns-3.34-release branch
|\
| * 3fab3cf (tag: ns-3.34) Update availability in RELEASE_NOTES
| * c50aaf7 Update VERSION and documentation tags for ns-3.34 release
|/
* 9df8ef4 doc: Update ns-3 version in tutorial examples
* 9319cdd Update CHANGES.html and RELEASE_NOTES

Let's assume that the changeset ee37d41 is considered important to fix in the ns-3.34 release, but we do not want the other changes introduced since then 🙈 The solution? Create a new branch for a hotfix release, and follow similar steps 🔥

The branch for the hotfix should come from commit 3fab3cf, and should cherry-pick commit ee37d41 (which may require a merge if it doesn't apply cleanly), and then the hotfix branch can be tagged and merged as was done before:

$ git checkout -b ns-3.34.1-release ns-3.34
$ git cherry-pick ee37d41
... (resolve any conflicts)
$ git add a
$ git commit
$ sed -i 's/3.34/3.34.1/g' VERSION
$ cat VERSION
3.34.1
$ git commit -m"Update VERSION to 3.34.1" VERSION
$ git tag -a 'ns-3.34.1' -m"ns-3.34.1 release"

Now the merge:

$ git checkout master
$ git merge --no-commit --no-ff ns-3.34.1-release

This time we may see something like:

Auto-merging a
CONFLICT (content): Merge conflict in a
Auto-merging VERSION
CONFLICT (content): Merge conflict in VERSION
Automatic merge failed; fix conflicts and then commit the result.

And we can then do:

$ git reset ee37d41 a
$ git reset ee37d41 VERSION

Which leaves us with:

Unstaged changes after reset:
M     VERSION
M     a

We can next hand-edit these files to restore them to original state, so that:

$ git status
On branch master
Your branch is ahead of 'origin/master' by 8 commits.
  (use "git push" to publish your local commits)

All conflicts fixed but you are still merging.
  (use "git commit" to conclude merge)

$ git commit
$ git branch -d ns-3.34.1-release

The new log should show something like the below, with parallel git history paths until the merge back again 🌈

$ git log --graph --decorate --oneline --all
*   815ce6e (HEAD -> master) Merge branch 'ns-3.34.1-release'
|\
| * 12a29ca (tag: ns-3.34.1) Update VERSION to 3.34.1
| * 21ebdbf Fix missing abc bug on file a
* | ee37d41 Fix missing abc bug on file a
* | 9a3432a some more changes
* | ba28d6d Add new feature
* | e50015a make some changes
* |   fd075f6 Merge ns-3.34-release branch
|\ \
| |/
| * 3fab3cf (tag: ns-3.34) Update availability in RELEASE_NOTES
| * c50aaf7 Update VERSION and documentation tags for ns-3.34 release
|/
* 9df8ef4 doc: Update ns-3 version in tutorial examples
* 9319cdd Update CHANGES.html and RELEASE_NOTES

$ git push origin master:master --follow-tags

And we can continue to commit on top of master going forward 💜 The two tags should be found in the git tag output (among other tags):

$ git tag
ns-3.34
ns-3.34.1

🎲 RANDOM VARIABLES 🎲

ns-3 contains a built-in pseudo-random number generator (PRNG) ✨ It is important for serious users of the simulator to understand the functionality, configuration, and usage of this PRNG, and to decide whether it is sufficient for his or her research use 👑


⚡ Quick Overview

ns-3 random numbers are provided via instances of ns3::RandomVariableStream 🌸

  • by default, ns-3 simulations use a fixed seed; if there's any randomness in the simulation, every single run of the program will spit out identical results unless you change the seed and/or the run number. no cap.
  • in ns-3.3 and earlier, ns-3 simulations used a random seed by default; this marks a change in policy starting with ns-3.4. (glow up era 💅)
  • in ns-3.14 and earlier, ns-3 simulations used a different wrapper class called ns3::RandomVariable. As of ns-3.15, this class has been replaced by ns3::RandomVariableStream; the underlying pseudo-random number generator has not changed. same engine, new fit ✨
  • to obtain randomness across multiple simulation runs, you must either set the seed differently or set the run number differently. To set a seed, call ns3::RngSeedManager::SetSeed() at the beginning of the program; to set a run number with the same seed, call ns3::RngSeedManager::SetRun() at the beginning of the program; see Creating random variables.
  • each RandomVariableStream used in ns-3 has a virtual random number generator associated with it; all random variables use either a fixed or random seed based on the use of the global seed (previous bullet);
  • if you intend to perform multiple runs of the same scenario, with different random numbers, please be sure to read the section on how to perform independent replications: Creating random variables.

Read further for more explanation about the random number facility for ns-3. 🧚


🌈 Background 🌈

Simulations use a lot of random numbers; one study found that most network simulations spend as much as 50% of the CPU just generating random numbers. 😭 Simulation users need to be concerned with the quality of the (pseudo) random numbers and the independence between different streams of random numbers.

Users need to be concerned with a few issues, such as:

  • the seeding of the random number generator and whether a simulation outcome is deterministic or not,
  • how to acquire different streams of random numbers that are independent from one another, and
  • how long it takes for streams to cycle

We will introduce a few terms here: a RNG provides a long sequence of (pseudo) random numbers. The length of this sequence is called the cycle length or period, after which the RNG will repeat itself. This sequence can be partitioned into disjoint streams. A stream of a RNG is a contiguous subset or block of the RNG sequence. For instance, if the RNG period is of length N, and two streams are provided from this RNG, then the first stream might use the first N/2 values and the second stream might produce the second N/2 values. An important property here is that the two streams are uncorrelated. Likewise, each stream can be partitioned disjointedly to a number of uncorrelated substreams. The underlying RNG hopefully produces a pseudo-random sequence of numbers with a very long cycle length, and partitions this into streams and substreams in an efficient manner. slay.

ns-3 uses the same underlying random number generator as does ns-2: the MRG32k3a generator from Pierre L'Ecuyer. A detailed description can be found in http://www.iro.umontreal.ca/~lecuyer/myftp/papers/streams00.pdf. The MRG32k3a generator provides 1.8x10^{19} independent streams of random numbers, each of which consists of 2.3x10^{15} substreams. Each substream has a period (i.e., the number of random numbers before overlap) of 7.6x10^{22}. The period of the entire generator is 3.1x10^{57}. those numbers are giving astronomical fr fr 🪐

Class ns3::RandomVariableStream is the public interface to this underlying random number generator. When users create new random variables (such as ns3::UniformRandomVariable, ns3::ExponentialRandomVariable, etc.), they create an object that uses one of the distinct, independent streams of the random number generator. Therefore, each object of type ns3::RandomVariableStream has, conceptually, its own "virtual" RNG. Furthermore, each ns3::RandomVariableStream can be configured to use one of the set of substreams drawn from the main stream.

An alternate implementation would be to allow each RandomVariable to have its own (differently seeded) RNG. However, we cannot guarantee as strongly that the different sequences would be uncorrelated in such a case; hence, we prefer to use a single RNG and streams and substreams from it. 👑


🎀 Creating random variables 🎀

ns-3 supports a number of random variable objects from the base class RandomVariableStream. These objects derive from ns3::Object and are handled by smart pointers.

The correct way to create these objects is to use the templated CreateObject<> method, such as:

Ptr<UniformRandomVariable> x = CreateObject<UniformRandomVariable> ();

then you can access values by calling methods on the object such as:

myRandomNo = x->GetInteger ();

If you try to instead do something like this:

myRandomNo = UniformRandomVariable().GetInteger ();

your program will encounter a segmentation fault, because the implementation relies on some attribute construction that occurs only when CreateObject is called. 💀 do not do this bestie, it's not the vibe.

Much of the rest of this chapter now discusses the properties of the stream of pseudo-random numbers generated from such objects, and how to control the seeding of such objects.


✨ Seeding and independent replications ✨

ns-3 simulations can be configured to produce deterministic or random results. If the ns-3 simulation is configured to use a fixed, deterministic seed with the same run number, it should give the same output each time it is run.

By default, ns-3 simulations use a fixed seed and run number. These values are stored in two ns3::GlobalValue instances: g_rngSeed and g_rngRun.

A typical use case is to run a simulation as a sequence of independent trials, so as to compute statistics on a large number of independent runs. The user can either change the global seed and rerun the simulation, or can advance the substream state of the RNG, which is referred to as incrementing the run number.

A class ns3::RngSeedManager provides an API to control the seeding and run number behavior. This seeding and substream state setting must be called before any random variables are created; e.g:

RngSeedManager::SetSeed (3);  // Changes seed from default of 1 to 3
RngSeedManager::SetRun (7);   // Changes run number from default of 1 to 7
// Now, create random variables
Ptr<UniformRandomVariable> x = CreateObject<UniformRandomVariable> ();
Ptr<ExponentialRandomVariable> y = CreateObject<ExponentialRandomVarlable> ();
...

Which is better, setting a new seed or advancing the substream state? There is no guarantee that the streams produced by two random seeds will not overlap. The only way to guarantee that two streams do not overlap is to use the substream capability provided by the RNG implementation. Therefore, use the substream capability to produce multiple independent runs of the same simulation. In other words, the more statistically rigorous way to configure multiple independent replications is to use a fixed seed and to advance the run number. This implementation allows for a maximum of 2.3x10^{15} independent replications using the substreams. periodt. 💅

For ease of use, it is not necessary to control the seed and run number from within the program; the user can set the NS_GLOBAL_VALUE environment variable as follows:

$ NS_GLOBAL_VALUE="RngRun=3" ./waf --run program-name

Another way to control this is by passing a command-line argument; since this is an ns-3 GlobalValue instance, it is equivalently done such as follows:

$ ./waf --command-template="%s --RngRun=3" --run program-name

or, if you are running programs directly outside of waf:

$ ./build/optimized/scratch/program-name --RngRun=3

The above command-line variants make it easy to run lots of different runs from a shell script by just passing a different RngRun index. chef's kiss, so convenient 🫶


🦋 Class RandomVariableStream 🦋

All random variables should derive from class RandomVariable. This base class provides a few methods for globally configuring the behavior of the random number generator. Derived classes provide API for drawing random variates from the particular distribution being supported.

Each RandomVariableStream created in the simulation is given a generator that is a new RNGStream from the underlying PRNG. Used in this manner, the L'Ecuyer implementation allows for a maximum of 1.8x10^19 random variables. Each random variable in a single replication can produce up to 7.6x10^22 random numbers before overlapping.


🌸 Base class public API 🌸

Below are excerpted a few public methods of class RandomVariableStream that access the next value in the substream.

/**
 * \brief Returns a random double from the underlying distribution
 * \return A floating point random value
 */
double GetValue (void) const;

/**
 * \brief Returns a random integer from the underlying distribution
 * \return  Integer cast of ::GetValue()
 */
uint32_t GetInteger (void) const;

We have already described the seeding configuration above. Different RandomVariable subclasses may have additional API.


🧁 Types of RandomVariables 🧁

The following types of random variables are provided, and are documented in the ns-3 Doxygen or by reading src/core/model/random-variable-stream.h. Users can also create their own custom random variables by deriving from class RandomVariableStream. main character energy fr 💜

  • class UniformRandomVariable
  • class ConstantRandomVariable
  • class SequentialRandomVariable
  • class ExponentialRandomVariable
  • class ParetoRandomVariable
  • class WeibullRandomVariable
  • class NormalRandomVariable
  • class LogNormalRandomVariable
  • class GammaRandomVariable
  • class ErlangRandomVariable
  • class TriangularRandomVariable
  • class ZipfRandomVariable
  • class ZetaRandomVariable
  • class DeterministicRandomVariable
  • class EmpiricalRandomVariable

💫 Semantics of RandomVariableStream objects 💫

RandomVariableStream objects derive from ns3::Object and are handled by smart pointers.

RandomVariableStream instances can also be used in ns-3 attributes, which means that values can be set for them through the ns-3 attribute system. An example is in the propagation models for WifiNetDevice:

TypeId
RandomPropagationDelayModel::GetTypeId (void)
{
  static TypeId tid = TypeId ("ns3::RandomPropagationDelayModel")
    .SetParent<PropagationDelayModel> ()
    .SetGroupName ("Propagation")
    .AddConstructor<RandomPropagationDelayModel> ()
    .AddAttribute ("Variable",
                   "The random variable which generates random delays (s).",
                   StringValue ("ns3::UniformRandomVariable"),
                   MakePointerAccessor (&RandomPropagationDelayModel::m_variable),
                   MakePointerChecker<RandomVariableStream> ())
    ;
  return tid;
}

okay so bestie, this is the moment: the ns-3 user can swap out the default random variable for this delay model (which, for the record, is a UniformRandomVariable ranging from 0 to 1) through the attribute system. ✨ customization era ✨


🎲 Using other PRNG

There is presently no support for substituting a different underlying random number generator (e.g., the GNU Scientific Library or the Akaroa package). Patches are welcome. 🫶 (like, genuinely — the door is open, come through)


🔢 Setting the stream number

The underlying MRG32k3a generator provides 2^64 independent streams. In ns-3, these are assigned sequentially starting from the first stream as new RandomVariableStream instances make their first call to GetValue().

As a result of how these RandomVariableStream objects are assigned to underlying streams, the assignment is sensitive to perturbations of the simulation configuration. The consequence is that if any aspect of the simulation configuration is changed, the mapping of RandomVariables to streams may (or may not) change. 😭 not the may-or-may-not, it's giving chaos

As a concrete example, a user running a comparative study between routing protocols may find that the act of changing one routing protocol for another will notice that the underlying mobility pattern also changed. 💀 (yes, this is real, yes, it is exactly as annoying as it sounds)

Starting with ns-3.15, some control has been provided to users to allow users to optionally fix the assignment of selected RandomVariableStream objects to underlying streams. This is the Stream attribute, part of the base class RandomVariableStream. 🎀

By partitioning the existing sequence of streams from before:

<-------------------------------------------------------------------------->
stream 0                                                   stream (2^64 - 1)

into two equal-sized sets:

<-------------------------------------------------------------------------->
^                                    ^^                                    ^
|                                    ||                                    |
stream 0            stream (2^63 - 1)  stream 2^63         stream (2^64 - 1)
<- automatically assigned -----------><- assigned by user ----------------->

The first 2^63 streams continue to be automatically assigned, while the last 2^63 are given stream indices starting with zero up to 2^63-1.

The assignment of streams to a fixed stream number is optional; instances of RandomVariableStream that do not have a stream value assigned will be assigned the next one from the pool of automatic streams.

To fix a RandomVariableStream to a particular underlying stream, assign its Stream attribute to a non-negative integer (the default value of -1 means that a value will be automatically allocated). 💅 so simple, so clean


📝 Publishing your results

When you publish simulation results, a key piece of configuration information that you should always state is how you used the random number generator. 🗣️ say it with your chest

  • what seeds you used,
  • what RNG you used if not the default,
  • how were independent runs performed,
  • for large simulations, how did you check that you did not cycle.

It is incumbent on the researcher publishing results to include enough information to allow others to reproduce his or her results. It is also incumbent on the researcher to convince oneself that the random numbers used were statistically valid, and to state in the paper why such confidence is assumed. 🧚 no cap, reproducibility is the whole vibe


✅ Summary

Let's review what things you should do when creating a simulation. 🫶

  • Decide whether you are running with a fixed seed or random seed; a fixed seed is the default,
  • Decide how you are going to manage independent replications, if applicable,
  • Convince yourself that you are not drawing more random values than the cycle length, if you are running a very long simulation, and
  • When you publish, follow the guidelines above about documenting your use of the random number generator.

🌈 HASH FUNCTIONS 🌈

ns-3 provides a generic interface to general purpose hash functions. In the simplest usage, the hash function returns the 32-bit or 64-bit hash of a data buffer or string. The default underlying hash function is murmur3, chosen because it has good hash function properties and offers a 64-bit version. The venerable FNV1a hash is also available. ✨

There is a straight-forward mechanism to add (or provide at run time) alternative hash function implementations. 👑


🍓 Basic Usage

The simplest way to get a hash value of a data buffer or string is just:

#include "ns3/hash.h"

using namespace ns3;

char * buffer = ...
size_t buffer_size = ...

uint32_t  buffer_hash = Hash32 ( buffer, buffer_size);

std::string s;
uint32_t  string_hash = Hash32 (s);

Equivalent functions are defined for 64-bit hash values. 💖


🧵 Incremental Hashing

In some situations it's useful to compute the hash of multiple buffers, as if they had been joined together. (For example, you might want the hash of a packet stream, but not want to assemble a single buffer with the combined contents of all the packets.) 🦋

This is almost as straight-forward as the first example:

#include "ns3/hash.h"

using namespace ns3;

char * buffer;
size_t buffer_size;

Hasher hasher;  // Use default hash function

for (<every buffer>)
  {
      buffer = get_next_buffer ();
      hasher (buffer, buffer_size);
  }
uint32_t combined_hash = hasher.GetHash32 ();

By default Hasher preserves internal state to enable incremental hashing. If you want to reuse a Hasher object (for example because it's configured with a non-default hash function), but don't want to add to the previously computed hash, you need to clear() first:

hasher.clear ().GetHash32 (buffer, buffer_size);

This reinitializes the internal state before hashing the buffer. 🎀


🦋 Using an Alternative Hash Function

The default hash function is murmur3. FNV1a is also available. To specify the hash function explicitly, use this constructor:

Hasher hasher = Hasher ( Create<Hash::Function::Fnv1a> () );

👑 Adding New Hash Function Implementations

To add the hash function foo, follow the hash-murmur3.h/.cc pattern:

  • Create a class declaration (.h) and definition (.cc) inheriting from Hash::Implementation.
  • include the declaration in hash.h (at the point where hash-murmur3.h is included.
  • In your own code, instantiate a Hasher object via the constructor Hasher (Ptr<Hash::Function::Foo> ())

If your hash function is a single function, e.g. hashf, you don't even need to create a new class derived from HashImplementation:

Hasher hasher =
  Hasher ( Create<Hash::Function::Hash32> (&hashf) );

For this to compile, your hashf has to match one of the function pointer signatures:

typedef uint32_t (*Hash32Function_ptr) (const char *, const size_t);
typedef uint64_t (*Hash64Function_ptr) (const char *, const size_t);

🍓 Sources for Hash Functions

Sources for other hash function implementations include:

💜 go forth and hash, bestie

✨ EVENTS AND SIMULATOR ✨

okay so here's the whole vibe of ns-3: it's a discrete-event network simulator 💅 conceptually, the simulator keeps track of a whole bunch of events that are all scheduled to execute at some specified simulation time, and its entire job is to run those events in sequential time order. once an event finishes doing its thing, the simulator moves on to the next one (or just exits if there are no more events left in the event queue). no cap.

so like, say an event scheduled for simulation time “100 seconds” gets executed, and the next event isn't scheduled until “200 seconds” — the simulator will literally just jump from 100 seconds straight to 200 seconds (of simulation time) to execute that next event. it does not sit around waiting, bestie. that's the entire point of calling it a “discrete-event” simulator 🦋

to make all this happen, the simulator needs a few things:

  1. a simulator object that can access an event queue where events are stored and that can manage the execution of events
  2. a scheduler responsible for inserting and removing events from the queue
  3. a way to represent simulation time
  4. the events themselves

this chapter of the manual describes these fundamental objects (simulator, scheduler, time, event) and how they are used 👑


🎀 Event 🎀

To be completed

💜 Simulator 💜

the Simulator class is the public entry point to access event scheduling facilities. once you've scheduled a couple of events to start the simulation, you can start executing them by entering the simulator main loop (call Simulator::Run). once the main loop starts running, it will sequentially execute all scheduled events in order from oldest to most recent until there are either no more events left in the event queue or Simulator::Stop has been called. periodt.

to schedule events for execution by the simulator main loop, the Simulator class provides the Simulator::Schedule* family of functions ✨

1. Handling event handlers with different signatures

these functions are declared and implemented as C++ templates so they can automatically handle the wide variety of C++ event handler signatures used in the wild. for example, to schedule an event to execute 10 seconds in the future, and invoke a C++ method or function with specific arguments, you might write this:

void handler (int arg0, int arg1)
{
  std::cout << "handler called with argument arg0=" << arg0 << " and
     arg1=" << arg1 << std::endl;
}

Simulator::Schedule(Seconds(10), &handler, 10, 5);

which will output:

handler called with argument arg0=10 and arg1=5

and of course, these C++ templates can also handle transparently member methods on C++ objects:

To be completed: member method example

Notes:

  • the ns-3 Schedule methods recognize automatically functions and methods only if they take less than 5 arguments. if you need them to support more arguments, please, file a bug report 🐛
  • readers familiar with the term ‘fully-bound functors’ will recognize the Simulator::Schedule methods as a way to automatically construct such objects. iykyk 💅

2. Common scheduling operations

the Simulator API was designed to make it really simple to schedule most events. it provides three variants to do so (ordered from most commonly used to least commonly used):

  • Schedule methods which allow you to schedule an event in the future by providing the delay between the current simulation time and the expiration date of the target event.
  • ScheduleNow methods which allow you to schedule an event for the current simulation time: they will execute after the current event is finished executing but before the simulation time is changed for the next event.
  • ScheduleDestroy methods which allow you to hook in the shutdown process of the Simulator to cleanup simulation resources: every ‘destroy’ event is executed when the user calls the Simulator::Destroy method.

3. Maintaining the simulation context

there are two basic ways to schedule events: with context and without context. what does that even mean?? let's see:

Simulator::Schedule (Time const &time, MEM mem_ptr, OBJ obj);

vs.

Simulator::ScheduleWithContext (uint32_t context, Time const &time, MEM mem_ptr, OBJ obj);

readers who invest time and effort in developing or using a non-trivial simulation model will know the value of the ns-3 logging framework to debug simple and complex simulations alike. one of the important features that is provided by this logging framework is the automatic display of the network node id associated with the ‘currently’ running event 🌸

the node id of the currently executing network node is in fact tracked by the Simulator class. it can be accessed with the Simulator::GetContext method, which returns the ‘context’ (a 32-bit integer) associated and stored in the currently-executing event. in some rare cases, when an event is not associated with a specific network node, its ‘context’ is set to 0xffffffff.

to associate a context to each event, the Schedule and ScheduleNow methods automatically reuse the context of the currently-executing event as the context of the event scheduled for execution later.

in some cases — most notably when simulating the transmission of a packet from a node to another — this behavior is undesirable, since the expected context of the reception event is that of the receiving node, not the sending node. to avoid this problem, the Simulator class provides a specific schedule method: ScheduleWithContext, which allows one to provide explicitly the node id of the receiving node associated with the receive event.

XXX: code example

in some very rare cases, developers might need to modify or understand how the context (node id) of the first event is set to that of its associated node. this is accomplished by the NodeList class: whenever a new node is created, the NodeList class uses ScheduleWithContext to schedule a ‘initialize’ event for this node. the ‘initialize’ event thus executes with a context set to that of the node id and can use the normal variety of Schedule methods. it invokes the Node::Initialize method which propagates the ‘initialize’ event by calling the DoInitialize method for each object associated with the node. the DoInitialize method overridden in some of these objects (most notably in the Application base class) will schedule some events (most notably Application::StartApplication) which will in turn scheduling traffic generation events which will in turn schedule network-level events. it's giving ✨cascade✨ bestie

Notes:

  • users need to be careful to propagate DoInitialize methods across objects by calling Initialize explicitly on their member objects
  • the context id associated with each ScheduleWithContext method has other uses beyond logging: it is used by an experimental branch of ns-3 to perform parallel simulation on multicore systems using multithreading.

the Simulator::* functions do not know what the context is: they merely make sure that whatever context you specify with ScheduleWithContext is available when the corresponding event executes with ::GetContext.

it is up to the models implemented on top of Simulator::* to interpret the context value. in ns-3, the network models interpret the context as the node id of the node which generated an event. this is why it is important to call ScheduleWithContext in ns3::Channel subclasses, because we are generating an event from node i to node j and we want to make sure that the event which will run on node j has the right context 👑

🦋 Time 🦋

To be completed

🌈 Scheduler 🌈

To be completed


💖 CALLBACKS 💖

some new users to ns-3 are unfamiliar with an extensively used programming idiom used throughout the code: the ns-3 callback. this chapter provides some motivation on the callback, guidance on how to use it, and details on its implementation 🎀

Callbacks Motivation

consider that you have two simulation models A and B, and you wish to have them pass information between them during the simulation. one way you can do that is to make A and B each explicitly knowledgeable about the other, so that they can invoke methods on each other:

class A {
public:
  void ReceiveInput ( // parameters );
  ...
}

(in another source file:)

class B {
public:
  void DoSomething (void);
  ...

private:
  A* a_instance; // pointer to an A
}

void
B::DoSomething()
{
  // Tell a_instance that something happened
  a_instance->ReceiveInput ( // parameters);
  ...
}

this certainly works, but it has the drawback that it introduces a dependency on A and B to know about the other at compile time (which makes it harder to have independent compilation units in the simulator) and it's not generalized. so if, in a later usage scenario, B needs to talk to a completely different C object, the source code for B needs to be changed to add a c_instance and so forth. it's easy to see that this is a brute force mechanism of communication that can lead to programming cruft in the models 💀

this is not to say that objects should not know about one another if there is a hard dependency between them, but that often the model can be made more flexible if its interactions are less constrained at compile time.

this is not an abstract problem for network simulation research, either — it has been a source of problems in previous simulators, when researchers want to extend or modify the system to do different things (as they are apt to do in research 🙈). consider, for example, a user who wants to add an IPsec security protocol sublayer between TCP and IP:

------------                   -----------
|   TCP    |                   |  TCP    |
------------                   -----------
     |           becomes ->        |
-----------                    -----------
|   IP    |                    | IPsec   |
-----------                    -----------
                                   |
                               -----------
                               |   IP    |
                               -----------

if the simulator has made assumptions, and hard coded into the code, that IP always talks to a transport protocol above, the user may be forced to hack the system to get the desired interconnections. this is clearly not an optimal way to design a generic simulator, bestie. it's giving spaghetti 🍝

Callbacks Background

NOTE: readers familiar with programming callbacks may skip this tutorial section. 🫶

the basic mechanism that allows one to address the problem above is known as a callback. the ultimate goal is to allow one piece of code to call a function (or method in C++) without any specific inter-module dependency. iconic.

okay so the vibe here is: you literally cannot just hand a function around like it's a phone number, you need indirection. You treat the address of the function being called as a variable. That variable is called a pointer-to-function variable. And the relationship between a function and a pointer-to-function is honestly no different from the relationship between an object and a pointer-to-object. 💅

In C, the canonical example of a pointer-to-function is a pointer-to-function-returning-integer (PFI). For a PFI taking one int parameter, you'd declare it like this bestie:

int (*pfi)(int arg) = 0;

What you get from this is a variable named simply pfi that's initialized to the value 0. If you wanna initialize this pointer to something meaningful, you gotta have a function with a matching signature. In this case:

int MyFunction (int arg) {}

Once you have that target, you can initialize the variable to point at your function like:

pfi = MyFunction;

And then you can call MyFunction indirectly using the more suggestive form of the call:

int result = (*pfi) (1234);

This one is suggestive because it looks like you're dereferencing the function pointer just like you'd dereference any pointer. But typically, people take advantage of the fact that the compiler knows what's going on and just use the shorter form:

int result = pfi (1234);

Notice that the function pointer obeys value semantics, so you can pass it around like any other value. Typically, when you use an asynchronous interface you'll pass some entity like this to a function which will perform an action and call back to let you know it completed. It calls back by following the indirection and executing the provided function. 🎀


Now in C++ you have the added complexity of objects. The analogy with the PFI above means you have a pointer to a member function returning an int (PMI) instead of the pointer to function returning an int (PFI). 😭

The declaration of the variable providing the indirection looks only slightly different:

int (MyClass::*pmi) (int arg) = 0;

This declares a variable named pmi just as the previous example declared a variable named pfi. Since the whole point is to call a method of an instance of a particular class, one must declare that method in a class:

class MyClass {
public:
  int MyMethod (int arg);
};

Given this class declaration, you'd then initialize that variable like this:

pmi = &MyClass::MyMethod;

This assigns the address of the code implementing the method to the variable, completing the indirection. ✨ In order to call a method, the code needs a this pointer. Which means there must be an object of MyClass to refer to. A simplistic example of this is just calling a method indirectly (think virtual function):

int (MyClass::*pmi) (int arg) = 0;  // Declare a PMI
pmi = &MyClass::MyMethod;           // Point at the implementation code

MyClass myClass;                    // Need an instance of the class
(myClass.*pmi) (1234);              // Call the method with an object ptr

Just like in the C example, you can use this in an asynchronous call to another module which will call back using a method and an object pointer. The straightforward extension one might consider is to pass a pointer to the object and the PMI variable. The module would just do:

(*objectPtr.*pmi) (1234);

to execute the callback on the desired object. 🌸


okay but wait — one might ask at this time, what's the point? The called module will have to understand the concrete type of the calling object in order to properly make the callback. Why not just accept this, pass the correctly typed object pointer and do object->Method(1234) in the code instead of the callback? 🙈 This is precisely the problem described above. What's needed is a way to decouple the calling function from the called class completely. This requirement led to the development of the Functor. 👑

A functor is the outgrowth of something invented in the 1960s called a closure. It's basically just a packaged-up function call, possibly with some state. 🫶

A functor has two parts, a specific part and a generic part, related through inheritance. The calling code (the code that executes the callback) will execute a generic overloaded operator () of a generic functor to cause the callback to be called. The called code (the code that wants to be called back) will have to provide a specialized implementation of the operator () that performs the class-specific work that caused the close-coupling problem above. 💅

With the specific functor and its overloaded operator () created, the called code then gives the specialized code to the module that will execute the callback (the calling code).

The calling code will take a generic functor as a parameter, so an implicit cast is done in the function call to convert the specific functor to a generic functor. This means that the calling module just needs to understand the generic functor type. It is decoupled from the calling code completely. ✨

The information one needs to make a specific functor is the object pointer and the pointer-to-method address.

The essence of what needs to happen is that the system declares a generic part of the functor:

template <typename T>
class Functor
{
public:
  virtual int operator() (T arg) = 0;
};

The caller defines a specific part of the functor that really is just there to implement the specific operator() method:

template <typename T, typename ARG>
class SpecificFunctor : public Functor<ARG>
{
public:
  SpecificFunctor(T* p, int (T::*_pmi)(ARG arg))
  {
    m_p = p;
    m_pmi = _pmi;
  }

  virtual int operator() (ARG arg)
  {
    (*m_p.*m_pmi)(arg);
  }
private:
  int (T::*m_pmi)(ARG arg);
  T* m_p;
};

Here's an example of the usage:

class A
{
public:
A (int a0) : a (a0) {}
int Hello (int b0)
{
  std::cout << "Hello from A, a = " << a << " b0 = " << b0 << std::endl;
}
int a;
};

int main()
{
  A a(10);
  SpecificFunctor<A, int> sf(&a, &A::Hello);
  sf(5);
}
NOTE: The previous code is not real ns-3 code. It is simplistic example code used only to illustrate the concepts involved and to help you understand the system more. Do not expect to find this code anywhere in the ns-3 tree. 😭

Notice that there are two variables defined in the class above. The m_p variable is the object pointer and m_pmi is the variable containing the address of the function to execute.

Notice that when operator() is called, it in turn calls the method provided with the object pointer using the C++ PMI syntax.

To use this, one could then declare some model code that takes a generic functor as a parameter:

void LibraryFunction (Functor functor);

The code that will talk to the model would build a specific functor and pass it to LibraryFunction:

MyClass myClass;
SpecificFunctor<MyClass, int> functor (&myclass, MyClass::MyMethod);

When LibraryFunction is done, it executes the callback using the operator() on the generic functor it was passed, and in this particular case, provides the integer argument:

void
LibraryFunction (Functor functor)
{
  // Execute the library function
  functor(1234);
}

Notice that LibraryFunction is completely decoupled from the specific type of the client. The connection is made through the Functor polymorphism. 🦋


The Callback API in ns-3 implements object-oriented callbacks using the functor mechanism. This callback API, being based on C++ templates, is type-safe; that is, it performs static type checks to enforce proper signature compatibility between callers and callees. It is therefore more type-safe to use than traditional function pointers, but the syntax may look imposing at first. 😅 This section is designed to walk you through the Callback system so that you can be comfortable using it in ns-3.

🌈 Using the Callback API 🌈

The Callback API is fairly minimal, providing only two services:

  1. callback type declaration: a way to declare a type of callback with a given signature, and,
  2. callback instantiation: a way to instantiate a template-generated forwarding callback which can forward any calls to another C++ class member method or C++ function.

This is best observed via walking through an example, based on samples/main-callback.cc. ✨

💅 Using the Callback API with static functions 💅

Consider a function:

static double
CbOne (double a, double b)
{
  std::cout << "invoke cbOne a=" << a << ", b=" << b << std::endl;
  return a;
}

Consider also the following main program snippet:

int main (int argc, char *argv[])
{
  // return type: double
  // first arg type: double
  // second arg type: double
  Callback<double, double, double> one;
}

This is an example of a C-style callback – one which does not include or need a this pointer. The function template Callback is essentially the declaration of the variable containing the pointer-to-function. In the example above, we explicitly showed a pointer to a function that returned an integer and took a single integer as a parameter. The Callback template function is a generic version of that – it's used to declare the type of a callback. 🎀

NOTE: Readers unfamiliar with C++ templates may consult http://www.cplusplus.com/doc/tutorial/templates/. 🦋

The Callback template requires one mandatory argument (the return type of the function to be assigned to this callback) and up to five optional arguments, which each specify the type of the arguments (if your particular callback function has more than five arguments, then this can be handled by extending the callback implementation).

So in the above example, we have declared a callback named one that will eventually hold a function pointer. The signature of the function that it will hold must return double and must support two double arguments. If one tries to pass a function whose signature does not match the declared callback, a compilation error will occur. Also, if one tries to assign to a callback an incompatible one, compilation will succeed but a run-time NS_FATAL_ERROR will be raised. 😱 The sample program src/core/examples/main-callback.cc demonstrates both of these error cases at the end of the main() program. 💖

Now, bestie, we gotta tie that callback instance to the actual target function (CbOne). 👀 Peep this: CbOne has the same function signature types as the callback — and that is not a coincidence, it's the whole vibe. This matters. A lot. You can pass in any properly-typed function to this callback. Let's zoom in:

   static   double CbOne (double a, double b) {}
              ^             ^         ^
              |             |         |
              |             |         |
   Callback<double,       double,   double> one;

The rule is simple, no cap: you can only bind a function to a callback if they have matching signatures. The first template argument is the return type, and the extra template arguments are the types of the arguments of the function signature. Periodt.

So now let's bind our callback one to the function that matches its signature:

// build callback instance which points to cbOne function
one = MakeCallback (&CbOne);

That call to MakeCallback is, in essence, creating one of the specialized functors we were just yapping about. The variable declared using the Callback template function is gonna be playing the part of the generic functor. And one = MakeCallback (&CbOne) is the cast that converts the specialized functor known to the callee into a generic functor known to the caller. ✨

Then, later in the program, if the callback is needed, it can be used like this:

NS_ASSERT (!one.IsNull ());

// invoke cbOne function through callback instance
double retOne;
retOne = one (10.0, 20.0);

The IsNull() check is your safety bestie — it makes sure the callback is not null, i.e. that there's actually a function to call behind this callback. Then one() executes the generic operator() which is really overloaded with a specific implementation of operator() and returns the same result as if CbOne() had been called directly. Iconic behavior honestly. 💅

Using the Callback API with member functions 💖

Generally you won't be calling static functions, bestie — you'll be calling public member functions of an object. In that case, MakeCallback needs an extra argument to tell the system on which object the function should be invoked. Consider this example, also from main-callback.cc:

class MyCb {
public:
  int CbTwo (double a) {
      std::cout << "invoke cbTwo a=" << a << std::endl;
      return -5;
  }
};

int main ()
{
  ...
  // return type: int
  // first arg type: double
  Callback<int, double> two;
  MyCb cb;
  // build callback instance which points to MyCb::cbTwo
  two = MakeCallback (&MyCb::CbTwo, &cb);
  ...
}

See how we pass an additional object pointer to the MakeCallback<> function? Recall from the background section above that Operator() will use the pointer-to-member syntax when it executes on an object:

virtual int operator() (ARG arg)
{
  (*m_p.*m_pmi)(arg);
}

And so we needed to provide the two variables (m_p and m_pmi) when we made the specific functor. This line:

two = MakeCallback (&MyCb::CbTwo, &cb);

does precisely that. 🫶 In this case, when two () is invoked:

int result = two (1.0);

it will result in a call to the CbTwo member function (method) on the object pointed to by &cb.

Building Null Callbacks 🙈

Callbacks can be null, so it may be wise to check before using them. There's a special construct for a null callback, and it's preferable to simply passing "0" as an argument — it's the MakeNullCallback<> construct:

two = MakeNullCallback<int, double> ();
NS_ASSERT (two.IsNull ());
Invoking a null callback is just like invoking a null function pointer: it will crash at runtime. 💀 So like… check first. Please.

Bound Callbacks 🎀

A very useful extension to the functor concept is that of a Bound Callback. Earlier we mentioned that closures were originally function calls packaged up for later execution. But notice: in all of the Callback descriptions above, there's no way to package up any parameters for use later — when the Callback is called via operator(), all of the parameters are provided by the calling function.

But what if we want the client function (the one that provides the callback) to provide some of the parameters? Alexandrescu calls the process of allowing a client to specify one of the parameters "binding". One of the parameters of operator() has been bound (fixed) by the client. Chef's kiss. 😌

Some of our pcap tracing code gives a nice example of this. There's a function that needs to be called whenever a packet is received, and this function calls an object that actually writes the packet to disk in the pcap file format. The signature of one of these functions will be:

static void DefaultSink (Ptr<PcapFileWrapper> file, Ptr<const Packet> p);

That static keyword means this is a static function which does not need a this pointer, so it'll be using C-style callbacks. We don't want the calling code to have to know about anything but the Packet. What we want in the calling code is just a call that looks like:

m_promiscSnifferTrace (m_currentPkt);

What we wanna do is bind the Ptr<PcapFileWriter> file to the specific callback implementation when it's created, and arrange for the operator() of the Callback to provide that parameter for free.

We provide the MakeBoundCallback template function for that purpose. It takes the same parameters as the MakeCallback template function but also takes the parameters to be bound. In the case of the example above:

MakeBoundCallback (&DefaultSink, file);

will create a specific callback implementation that knows to add in the extra bound arguments. Conceptually, it extends the specific functor described above with one or more bound arguments:

template <typename T, typename ARG, typename BOUND_ARG>
class SpecificFunctor : public Functor
 {
 public:
    SpecificFunctor(T* p, int (T::*_pmi)(ARG arg), BOUND_ARG boundArg)
    {
      m_p = p;
      m_pmi = pmi;
      m_boundArg = boundArg;
    }

    virtual int operator() (ARG arg)
    {
      (*m_p.*m_pmi)(m_boundArg, arg);
    }
private:
    void (T::*m_pmi)(ARG arg);
    T* m_p;
    BOUND_ARG m_boundArg;
 };

You can see that when the specific functor is created, the bound argument is saved in the functor / callback object itself. 🦋 And when the operator() is invoked with the single parameter, as in:

m_promiscSnifferTrace (m_currentPkt);

the implementation of operator() adds the bound parameter into the actual function call:

(*m_p.*m_pmi)(m_boundArg, arg);

It's possible to bind two or three arguments as well, bestie. Say we have a function with signature:

static void NotifyEvent (Ptr<A> a, Ptr<B> b, MyEventType e);

One can create a bound callback binding the first two arguments like:

MakeBoundCallback (&NotifyEvent, a1, b1);

assuming a1 and b1 are objects of type A and B respectively. Similarly, for three arguments one would have a function with a signature:

static void NotifyEvent (Ptr<A> a, Ptr<B> b, MyEventType e);

Binding three arguments is done with:

MakeBoundCallback (&NotifyEvent, a1, b1, c1);

again assuming a1, b1 and c1 are objects of type A, B and C respectively.

This kind of binding can be used for exchanging information between objects in simulation; specifically, bound callbacks can be used as traced callbacks, which will be described in the next section. 👑

Traced Callbacks ⭐

Placeholder subsection

Callback locations in ns-3 🌈

Where are callbacks frequently used in ns-3? Here are some of the more visible ones to typical users:

  • Socket API
  • Layer-2/Layer-3 API
  • Tracing subsystem
  • API between IP and routing subsystems

Implementation details 🧚

The code snippets above are simplistic and only designed to illustrate the mechanism itself. The actual Callback code is quite complicated and very template-intense, and a deep understanding of the code is not required. If interested, expert users may find the following useful.

The code was originally written based on the techniques described in http://www.codeproject.com/cpp/TTLFunction.asp. It was subsequently rewritten to follow the architecture outlined in Modern C++ Design, Generic Programming and Design Patterns Applied, Alexandrescu, chapter 5, Generalized Functors.

This code uses:

  • default template parameters to saves users from having to specify empty parameters when the number of parameters is smaller than the maximum supported number
  • the pimpl idiom: the Callback class is passed around by value and delegates the crux of the work to its pimpl pointer.
  • two pimpl implementations which derive from CallbackImpl. FunctorCallbackImpl can be used with any functor-type while MemPtrCallbackImpl can be used with pointers to member functions.
  • a reference list implementation to implement the Callback's value semantics.

This code most notably departs from the Alexandrescu implementation in that it does not use type lists to specify and pass around the types of the callback arguments. Of course, it also does not use copy-destruction semantics and relies on a reference list rather than autoPtr to hold the pointer.


👑 OBJECT MODEL 👑

ns-3 is fundamentally a C++ object system. Objects can be declared and instantiated as usual, per C++ rules. ns-3 also adds some features to traditional C++ objects, as described below, to provide greater functionality and features. This manual chapter is intended to introduce the reader to the ns-3 object model. 🫶

This section describes the C++ class design for ns-3 objects. In brief, several design patterns in use include classic object-oriented design (polymorphic interfaces and implementations), separation of interface and implementation, the non-virtual public interface design pattern, an object aggregation facility, and reference counting for memory management. Those familiar with component models such as COM or Bonobo will recognize elements of the design in the ns-3 object aggregation model, although the ns-3 design is not strictly in accordance with either. 💜

Object-oriented behavior ✨

C++ objects, in general, provide common object-oriented capabilities (abstraction, encapsulation, inheritance, and polymorphism) that are part of classic object-oriented design. ns-3 objects make use of these properties; for instance:

...and here's what that class shape looks like, bestie:

class Address
{
public:
  Address ();
  Address (uint8_t type, const uint8_t *buffer, uint8_t len);
  Address (const Address & address);
  Address &operator = (const Address &address);
  ...
private:
  uint8_t m_type;
  uint8_t m_len;
  ...
};

Object base classes 🧬

okay so there are three special base classes in ns-3, and if you inherit from them your classes get to instantiate objects with special properties. the trio is:

  • class Object
  • class ObjectBase
  • class SimpleRefCount

ngl, it's not required that ns-3 objects inherit from any of these — but the ones that do get the special sauce. so what does each one unlock? 🔓

Classes deriving from class Object get:

  • the ns-3 type and attribute system (peep Attributes for that)
  • an object aggregation system
  • a smart-pointer reference counting system (class Ptr)

Classes that derive from class ObjectBase get the first two properties up there ☝️ but no smart pointers. and classes deriving from SimpleRefCount get only the smart-pointer reference counting system. that's it. that's the whole vibe.

in practice? class Object is the one the ns-3 dev is gonna bump into like 99% of the time. periodt. 👑


Memory management and class Ptr 🧠

memory management in C++ is a whole complex situation and fr fr it gets done wrong or inconsistently all the time. so ns-3 settled on a reference counting design, and here's how it goes:

every object that uses reference counting keeps an internal reference count to figure out when it can safely delete itself. each time a pointer gets obtained to an interface, the object's reference count goes ⬆️ by calling Ref(). and it's your job as the pointer user to explicitly Unref() it when you're done. when the count hits zero, the object gets deleted. bye bestie 💀

  • when client code gets a pointer from the object itself — through object creation, or via GetObject — it does not have to increment the reference count.
  • when client code gets a pointer from another source (like copying a pointer), it must call Ref() to increment the count.
  • all users of the object pointer must call Unref() to release the reference.

the whole "ugh do I have to call Unref()" burden gets eased a little by the reference counting smart pointer class we're about to talk about. 🫶

also, heads up: if you're using a low-level API and you wanna explicitly allocate non-reference-counted objects on the heap with operator new, then you are responsible for deleting those objects. nobody's coming to save you. 💅

Reference counting smart pointer (Ptr) ✨

calling Ref() and Unref() constantly would be soooo cumbersome, so ns-3 hands you a smart pointer class Ptr that's similar to Boost::intrusive_ptr. this smart-pointer class just assumes the underlying type gives you a pair of Ref and Unref methods that increment and decrement the internal refcount of the object instance.

the way this implementation works, you can manipulate the smart pointer like it was a totally normal pointer — compare it with zero, compare it against other pointers, assign zero to it, all of that. 🎀

and if you ever need the raw pointer back out of the smart pointer, you can extract it with the GetPointer() and PeekPointer() methods.

if you wanna store a newed object into a smart pointer, we recommend you use the CreateObject template functions to create the object and stash it in a smart pointer — that way you avoid memory leaks. these functions are really small convenience functions, and their whole goal is just to save you a tiny bit of typing. lowkey iconic of them. 🌸


CreateObject and Create 🏗️

objects in C++ can be created statically, dynamically, or automatically — and yeah, same goes for ns-3. but some objects in the system have extra frameworks available. specifically, reference counted objects are usually allocated using a templated Create or CreateObject method, like this:

for objects deriving from class Object:

Ptr<WifiNetDevice> device = CreateObject<WifiNetDevice> ();
please please please do not create these objects using operator new — use CreateObject() instead. 🙈

for objects deriving from class SimpleRefCount, or any other objects that support usage of the smart pointer class, there's a templated helper function that's available and recommended:

Ptr<B> b = Create<B> ();

this is literally just a wrapper around operator new that correctly handles the reference counting system.

so, in summary (write this down 📝): use Create<B> if B is not an object but just uses reference counting (e.g. Packet), and use CreateObject<B> if B derives from ns3::Object.


Aggregation 🧩

okay so the ns-3 object aggregation system is motivated — in strong part — by recognizing that a super common use case for ns-2 was using inheritance and polymorphism to extend protocol models. like, specialized versions of TCP such as RenoTcpAgent derive from (and override functions from) class TcpAgent.

but two problems popped up in the ns-2 model: downcasts and the “weak base class” situation.

  • Downcasting — that's the procedure of using a base class pointer to an object and querying it at run time to figure out type info, which you then use to explicitly cast the pointer to a subclass pointer so you can use the subclass API.
  • Weak base class — that's the problems that show up when a class can't be effectively reused (derived from) because it's missing necessary functionality. then the dev is forced to go modify the base class, which causes a whole proliferation of base class API calls — and some of those calls might not even be semantically correct for all the subclasses. messy. 😭

ns-3 avoids all this by using a version of the query interface design pattern. this design is based on elements of the Component Object Model and GNOME Bonobo — though full binary-level compatibility of replaceable components is not supported, and we tried to simplify the syntax and the impact on model developers.


Examples 💫

Aggregation example

Node is a great example of aggregation in action in ns-3. note that there are no derived classes of Nodes in ns-3 — like, no class InternetNode. instead, components (protocols) get aggregated to a node. let's see how some Ipv4 protocols get added to a node:

static void
AddIpv4Stack(Ptr<Node> node)
{
  Ptr<Ipv4L3Protocol> ipv4 = CreateObject<Ipv4L3Protocol> ();
  ipv4->SetNode (node);
  node->AggregateObject (ipv4);
  Ptr<Ipv4Impl> ipv4Impl = CreateObject<Ipv4Impl> ();
  ipv4Impl->SetIpv4 (ipv4);
  node->AggregateObject (ipv4Impl);
}

notice the Ipv4 protocols are created with CreateObject(), and then they get aggregated to the node. this way the Node base class doesn't need to be edited at all to let users with a base class Node pointer access the Ipv4 interface — users can just ask the node for a pointer to its Ipv4 interface at runtime. and how the user asks? that's the next subsection, bestie. 👀

⚠️ important: it is a programming error to aggregate more than one object of the same type to an ns3::Object. so like, aggregation is not an option for storing all of the active sockets of a node. don't try it. 💀

GetObject example

GetObject is a type-safe way to achieve a safe downcasting, and it lets interfaces be found on an object.

say you've got a node pointer m_node pointing to a Node object that has an implementation of IPv4 already aggregated to it. your client code wants to configure a default route. to do that, it has to access an object within the node that has an interface to the IP forwarding configuration. so it does:

Ptr<Ipv4> ipv4 = m_node->GetObject<Ipv4> ();

if the node actually doesn't have an Ipv4 object aggregated to it, then the method returns null. so it's good practice to check the return value from a call like this. if it succeeded, you can now use the Ptr to the Ipv4 object that was previously aggregated to the node. slay. 💅

another example of using aggregation is adding optional models to objects. for instance, an existing Node object might have an “Energy Model” object aggregated to it at run time — and that's without modifying and recompiling the node class. then an existing model (like a wireless net device) can later GetObject for the energy model and act appropriately if the interface has been either built in to the underlying Node object or aggregated to it at run time. meanwhile the other nodes don't need to know anything about energy models. boundaries. we love them. ✨

we hope this mode of programming means developers need to modify base classes way less. 🫶


Object factories 🏭

a common use case is creating a whole bunch of similarly configured objects. you can just repeatedly call CreateObject(), but there's also a factory design pattern in use in the ns-3 system — and it's heavily used in the “helper” API.

Class ObjectFactory can be used to instantiate objects and to configure the attributes on those objects:

void SetTypeId (TypeId tid);
void Set (std::string name, const AttributeValue &value);
Ptr<T> Create (void) const;

the first method lets you use the ns-3 TypeId system to specify what type of objects get created. the second lets you set attributes on the objects that are gonna be created. and the third lets you create the objects themselves. the whole trio. 👑

for example:

ObjectFactory factory;
// Make this factory create objects of type FriisPropagationLossModel
factory.SetTypeId ("ns3::FriisPropagationLossModel")
// Make this factory object change a default value of an attribute, for
// subsequently created objects
factory.Set ("SystemLoss", DoubleValue (2.0));
// Create one such object
Ptr<Object> object = factory.Create ();
factory.Set ("SystemLoss", DoubleValue (3.0));
// Create another object with a different SystemLoss
Ptr<Object> object = factory.Create ();

Downcasting 🔍

a question that keeps coming up is: “if I have a base class pointer (Ptr) to an object and I want the derived class pointer, should I downcast (via C++ dynamic cast) to get the derived pointer, or should I use the object aggregation system to GetObject<> () to find a Ptr to the interface to the subclass API?”

and the answer is... in many situations, both techniques will work. but ns-3 gives you a templated function that makes the syntax of Object dynamic casting way more user friendly:

template <typename T1, typename T2>
Ptr<T1>
DynamicCast (Ptr<T2> const&p)
{
  return Ptr<T1> (dynamic_cast<T1 *> (PeekPointer (p)));
}

DynamicCast is the move when you've got a base-type pointer and you're checking it against a subclass pointer. GetObject is for hunting down other aggregated objects, but it also plays nice with subclasses — same energy as DynamicCast, fr. If you're unsure? Just use GetObject, bestie, because it works in literally every case. But if you already know the class hierarchy of the object you're looking at, it's more direct to just reach for DynamicCast. 💅


🌸 CONFIGURATION AND ATTRIBUTES 🌸

So in ns-3 simulations there are two big aspects to configuration, and I need you to hold both of these in your head:

  • The simulation topology and how objects are connected to each other.
  • The values used by the models you instantiated in that topology.

This chapter is obsessed with the second one: how all the many values floating around in ns-3 get organized, documented, and modified by ns-3 users. And plot twist — the ns-3 attribute system is also the whole foundation of how traces and statistics get gathered in the simulator. So yeah, it matters. 👑

Over the course of this chapter we're gonna talk about the various ways you can set or modify the values used by ns-3 model objects. In increasing order of specificity, here they are:

  • Default Attribute values set when Attributes are defined in GetTypeId (). — Scope: affects all instances of the class.
  • CommandLine, Config::SetDefault(), ConfigStore — Scope: affects all future instances.
  • ObjectFactory — Scope: affects all instances created with the factory.
  • Helper methods with (string/AttributeValue) parameter pairs — Scope: affects all instances created by the helper.
  • MyClass::SetX (), Object::SetAttribute (), Config::Set() — Scope: alters this particular instance. Generally this is the only form which can be scheduled to alter an instance once the simulation is running.

By "specificity" we mean that the methods further down that list override the values set by — and typically affect fewer instances than — the methods above them. It's giving narrowing focus, and I'm obsessed. ✨

Before we go diving into the details of the attribute value system, it'll help a lot to review some basic properties of class Object.

🦋 Object Overview 🦋

ns-3 is fundamentally a C++ object-based system. What that means is: new C++ classes (types) can be declared, defined, and subclassed just like normal. No cap.

A whole bunch of ns-3 objects inherit from the Object base class. And those objects come with some extra properties that we exploit for organizing the system and improving the memory management of our objects:

  • A "Metadata" system that links the class name to a lot of meta-information about the object, including:
    • The base class of the subclass,
    • The set of accessible constructors in the subclass,
    • The set of "attributes" of the subclass,
    • Whether each attribute can be set, or is read-only,
    • The allowed range of values for each attribute.
  • A reference counting smart pointer implementation, for memory management.

ns-3 objects that use the attribute system derive from either Object or ObjectBase. Most ns-3 objects we'll talk about derive from Object, but a few that live outside the smart pointer memory management framework derive from ObjectBase instead. 🙈

Let's review a couple of properties of these objects.

💖 Smart Pointers 💖

As we introduced back in the ns-3 tutorial, ns-3 objects are memory managed by a reference counting smart pointer implementation — that's class Ptr.

Smart pointers are used everywhere in the ns-3 APIs, and the reason is to avoid passing references to heap-allocated objects that could cause memory leaks. Leaks? Not on my watch. 💀 For most basic usage (syntax-wise), just treat a smart pointer like a regular pointer:

Ptr<WifiNetDevice> nd = ...;
nd->CallSomeFunction ();
// etc.

So how do you actually get a smart pointer to an object, like that first line up there? Great question, glad you asked.

🎀 CreateObject 🎀

Like we discussed above in Memory-management-and-class-Ptr, at the lowest-level API, objects of type Object are not instantiated using operator new as usual — instead they come from a templated function called CreateObject ().

A typical way to create such an object goes like this:

Ptr<WifiNetDevice> nd = CreateObject<WifiNetDevice> ();

You can think of this as being functionally equivalent to:

WifiNetDevice* nd = new WifiNetDevice ();

Objects that derive from Object must be allocated on the heap using CreateObject (). Meanwhile, things deriving from ObjectBase — like ns-3 helper functions and packet headers and trailers — can be allocated on the stack. Two different lanes, both valid. 🫶

In some scripts you might not see a lot of CreateObject () calls in the code, and that's not because they vanished — it's because there are some helper objects in effect that are doing the CreateObject () calls for you. Sneaky little icons. 👑

🌈 TypeId 🌈

ns-3 classes that derive from class Object can include a metadata class called TypeId that records meta-information about the class, for use in the object aggregation and component manager systems:

  • A unique string identifying the class.
  • The base class of the subclass, within the metadata system.
  • The set of accessible constructors in the subclass.
  • A list of publicly accessible properties ("attributes") of the class.

⭐ Object Summary ⭐

Putting all of these concepts together, let's look at a specific example: class Node.

The public header file node.h has a declaration that includes a static GetTypeId () function call:

class Node : public Object
{
public:
  static TypeId GetTypeId (void);
  ...

And this is defined in the node.cc file as follows:

TypeId
Node::GetTypeId (void)
{
  static TypeId tid = TypeId ("ns3::Node")
    .SetParent<Object> ()
    .SetGroupName ("Network")
    .AddConstructor<Node> ()
    .AddAttribute ("DeviceList",
                   "The list of devices associated to this Node.",
                   ObjectVectorValue (),
                   MakeObjectVectorAccessor (&Node::m_devices),
                   MakeObjectVectorChecker<NetDevice> ())
    .AddAttribute ("ApplicationList",
                   "The list of applications associated to this Node.",
                   ObjectVectorValue (),
                   MakeObjectVectorAccessor (&Node::m_applications),
                   MakeObjectVectorChecker<Application> ())
    .AddAttribute ("Id",
                   "The id (unique integer) of this Node.",
                   TypeId::ATTR_GET, // allow only getting it.
                   UintegerValue (0),
                   MakeUintegerAccessor (&Node::m_id),
                   MakeUintegerChecker<uint32_t> ())
    ;
  return tid;
}

Okay so think of the TypeId of the ns-3 Object class as an extended form of run time type information (RTTI). The C++ language includes a simple kind of RTTI in order to support dynamic_cast and typeid operators — this is the glow-up version. ✨

That SetParent<Object> () call in the definition above gets used in conjunction with our object aggregation mechanisms, so that safe up- and down-casting in inheritance trees during GetObject () is possible. It also enables subclasses to inherit the Attributes of their parent class. Family inheritance but make it C++. 💅

The AddConstructor<Node> () call works together with our abstract object factory mechanisms so we can construct C++ objects without forcing a user to know the concrete class of the object she's building. Iconic, honestly.

The three calls to AddAttribute () associate a given string with a strongly typed value in the class. Notice that you must provide a help string, which may be displayed — for example, via command line processors. Each Attribute is associated with mechanisms for accessing the underlying member variable in the object (for example, MakeUintegerAccessor () tells the generic Attribute code how to get to the node ID above). There are also "Checker" methods, which are used to validate values against range limitations, such as maximum and minimum allowed values. Range checks? We love a boundary. 🎀

When users want to create Nodes, they'll usually call some form of CreateObject ():

Ptr<Node> n = CreateObject<Node> ();

Or, more abstractly, using an object factory, you can create a Node object without even knowing the concrete C++ type:

ObjectFactory factory;
const std::string typeId = "ns3::Node'';
factory.SetTypeId (typeId);
Ptr<Object> node = factory.Create <Object> ();

Both of these methods result in fully initialized attributes being available in the resulting Object instances. Chef's kiss. 😭

Next up, we discuss how attributes — those values associated with member variables or functions of the class — get plumbed into the above TypeId.

🍓 Attributes 🍓

The goal of the attribute system is to organize the access of internal member objects of a simulation. Why does this goal even exist? Because typically, in simulation, users will cut and paste / modify existing simulation scripts, or will use higher-level simulation constructs, but they're often interested in studying or tracing particular internal variables. For instance, use cases such as:

  • "I want to trace the packets on the wireless interface only on the first access point."
  • “I want to trace the value of the TCP congestion window (every time it changes) on a particular TCP socket.” bestie, that's a whole mood 😭
  • “I want a dump of all values that were used in my simulation.” also valid, also iconic 💅

Same energy here: users might want fine-grained access to internal variables in the simulation, or they might want to broadly change the initial value used for a particular parameter in all subsequently created objects. And finally, users may wish to know what variables are settable and retrievable in a simulation configuration. And ngl this isn't just for direct simulation interaction on the command line — think about a (future) graphical user interface that would like to be able to provide a feature whereby a user might right-click on a node on the canvas and see a hierarchical, organized list of parameters that are settable on the node and its constituent member objects, plus help text and default values for each parameter. main character GUI energy ✨


🌸 Defining Attributes 🌸

We provide a way for users to access values deep in the system, without having to plumb accessors (pointers) through the system and walk pointer chains to get to them. Lowkey a lifesaver, fr fr. Consider a class QueueBase that has a member variable m_maxSize controlling the depth of the queue.

If we look at the declaration of QueueBase, we see the following:

class QueueBase : public Object {
public:
  static TypeId GetTypeId (void);
  ...

private:
  ...
  QueueSize m_maxSize;                //!< max queue size
  ...
};

QueueSize is a special type in ns-3 that allows size to be represented in different units:

enum QueueSizeUnit
{
  PACKETS,     /**< Use number of packets for queue size */
  BYTES,       /**< Use number of bytes for queue size */
};

class QueueSize
{
  ...
private:
  ...
  QueueSizeUnit m_unit; //!< unit
  uint32_t m_value;     //!< queue size [bytes or packets]
};

Finally, the class DropTailQueue inherits from this base class and provides the semantics that packets that are submitted to a full queue will be dropped from the back of the queue (“drop tail”).

/**
 * \ingroup queue
 *
 * \brief A FIFO packet queue that drops tail-end packets on overflow
 */
template <typename Item>
class DropTailQueue : public Queue<Item>

Let’s consider things that a user may want to do with the value of m_maxSize:

  • Set a default value for the system, such that whenever a new DropTailQueue is created, this member is initialized to that default. 🎀
  • Set or get the value on an already instantiated queue. 💅

The above things typically require providing Set () and Get () functions, and some type of global default value.

In the ns-3 attribute system, these value definitions and accessor function registrations are moved into the TypeId class; e.g.:

NS_OBJECT_ENSURE_REGISTERED (QueueBase);

TypeId
QueueBase::GetTypeId (void)
{
  static TypeId tid = TypeId ("ns3::DropTailQueue")
    .SetParent<Queue> ()
    .SetGroupName ("Network")
    ...
    .AddAttribute ("MaxSize",
                   "The max queue size",
                   QueueSizeValue (QueueSize ("100p")),
                   MakeQueueSizeAccessor (&QueueBase::SetMaxSize,
                                          &QueueBase::GetMaxSize),
                   MakeQueueSizeChecker ())
    ...
    ;

  return tid;
}

The AddAttribute () method is performing a number of things for the m_maxSize value:

  • Binding the (usually private) member variable m_maxSize to a public string "MaxSize". ✨
  • Providing a default value (0 packets).
  • Providing some help text defining the meaning of the value.
  • Providing a “Checker” (not used in this example) that can be used to set bounds on the allowable range of values.

The key point is that now the value of this variable and its default value are accessible in the attribute namespace, which is based on strings such as "MaxSize" and TypeId name strings. In the next section, we will provide an example script that shows how users may manipulate these values. 🦋

Note that initialization of the attribute relies on the macro NS_OBJECT_ENSURE_REGISTERED (QueueBase) being called; if you leave this out of your new class implementation, your attributes will not be initialized correctly. 💀 don't skip it bestie.

While we have described how to create attributes, we still haven’t described how to access and manage these values. For instance, there is no globals.h header file where these are stored; attributes are stored with their classes. Questions that naturally arise are how do users easily learn about all of the attributes of their models, and how does a user access these attributes, or document their values as part of the record of their simulation?

Detailed documentation of the actual attributes defined for a type, and a global list of all defined attributes, are available in the API documentation. For the rest of this document we are going to demonstrate the various ways of getting and setting attribute values. 🫶


🎀 Setting Default Values 🎀

🌈 Config::SetDefault and CommandLine 🌈

Let’s look at how a user script might access a specific attribute value. We’re going to use the src/point-to-point/examples/main-attribute-value.cc script for illustration, with some details stripped out. The main function begins:

// This is a basic example of how to use the attribute system to
// set and get a value in the underlying system; namely, the maximum
// size of the FIFO queue in the PointToPointNetDevice
//

int
main (int argc, char *argv[])
{

  // Queues in ns-3 are objects that hold items (other objects) in
  // a queue structure.  The C++ implementation uses templates to
  // allow queues to hold various types of items, but the most
  // common is a pointer to a packet (Ptr<Packet>).
  //
  // The maximum queue size can either be enforced in bytes ('b') or
  // packets ('p').  A special type called the ns3::QueueSize can
  // hold queue size values in either unit (bytes or packets).  The
  // queue base class ns3::QueueBase has a MaxSize attribute that can
  // be set to a QueueSize.

  // By default, the MaxSize attribute has a value of 100 packets ('100p')
  // (this default can be observed in the function QueueBase::GetTypeId)
  //
  // Here, we set it to 80 packets.  We could use one of two value types:
  // a string-based value or a QueueSizeValue value
  Config::SetDefault ("ns3::QueueBase::MaxSize", StringValue ("80p"));
  // The below function call is redundant
  Config::SetDefault ("ns3::QueueBase::MaxSize", QueueSizeValue (QueueSize (QueueSizeUnit::PACKETS, 80)));

The main thing to notice in the above are the two equivalent calls to Config::SetDefault (). This is how we set the default value for all subsequently instantiated DropTailQueues. We illustrate that two types of Value classes, a StringValue and a QueueSizeValue class, can be used to assign the value to the attribute named by “ns3::QueueBase::MaxSize”. 👑

It is also possible to manipulate Attributes using the CommandLine; we saw some examples early in the ns-3 Tutorial. In particular, it is straightforward to add a shorthand argument name, such as --maxSize, for an Attribute that is particular relevant for your model, in this case "ns3::QueueBase::MaxSize". This has the additional feature that the help string for the Attribute will be printed as part of the usage message for the script. For more information see the CommandLine API documentation.

// Allow the user to override any of the defaults and the above
// SetDefaults() at run-time, via command-line arguments
// For example, via "--ns3::QueueBase::MaxSize=80p"
CommandLine cmd;
// This provides yet another way to set the value from the command line:
cmd.AddValue ("maxSize", "ns3::QueueBase::MaxSize");
cmd.Parse (argc, argv);

Now, we will create a few objects using the low-level API. Our newly created queues will not have m_maxSize initialized to 0 packets, as defined in the QueueBase::GetTypeId () function, but to 80 packets, because of what we did above with default values.:

Ptr<Node> n0 = CreateObject<Node> ();

Ptr<PointToPointNetDevice> net0 = CreateObject<PointToPointNetDevice> ();
n0->AddDevice (net0);

Ptr<Queue<Packet> > q = CreateObject<DropTailQueue<Packet> > ();
net0->AddQueue(q);

At this point, we have created a single Node (n0) and a single PointToPointNetDevice (net0), added a DropTailQueue (q) to net0, which will be configured with a queue size limit of 80 packets. 🍓

As a final note, the Config::Set…() functions will throw an error if the targeted Attribute does not exist at the path given. There are also “fail-safe” versions, Config::Set…FailSafe(), if you can’t be sure the Attribute exists. The fail-safe versions return true if at least one instance could be set.


✨ Constructors, Helpers and ObjectFactory ✨

Arbitrary combinations of attributes can be set and fetched from the helper and low-level APIs; either from the constructors themselves:

Ptr<GridPositionAllocator> p =
  CreateObjectWithAttributes<GridPositionAllocator>
    ("MinX", DoubleValue (-100.0),
     "MinY", DoubleValue (-100.0),
     "DeltaX", DoubleValue (5.0),
     "DeltaY", DoubleValue (20.0),
     "GridWidth", UintegerValue (20),
     "LayoutType", StringValue ("RowFirst"));

or from the higher-level helper APIs, such as:

mobility.SetPositionAllocator
    ("ns3::GridPositionAllocator",
     "MinX", DoubleValue (-100.0),
     "MinY", DoubleValue (-100.0),
     "DeltaX", DoubleValue (5.0),
     "DeltaY", DoubleValue (20.0),
     "GridWidth", UintegerValue (20),
     "LayoutType", StringValue ("RowFirst"));

We don’t illustrate it here, but you can also configure an ObjectFactory with new values for specific attributes. Instances created by the ObjectFactory will have those attributes set during construction. This is very similar to using one of the helper APIs for the class. 💜

To review, there are several ways to set values for attributes for class instances to be created in the future:

  • Config::SetDefault ()
  • CommandLine::AddValue ()
  • CreateObjectWithAttributes<> ()
  • Various helper APIs

But what if you’ve already created an instance, and you want to change the value of the attribute? In this example, how can we manipulate the m_maxSize value of the already instantiated DropTailQueue? Here are various ways to do that. 👀 stay tuned bestie —

💅 Changing Values 💅

OK bestie, so you've got your attributes all set up — now how do you actually poke them? There's a whole lil menu of ways, and we're gonna walk through every single one. 🫶

✨ SmartPointer ✨

Say you're holding a smart pointer (Ptr) to the network device you care about. In this example that's the net0 pointer, bestie. She's right there in your hand.

One way to change the value is to grab a pointer to the underlying queue and mess with its attribute directly. 💅

First up: clock that we can get a pointer to the (base class) Queue through the PointToPointNetDevice attributes, where it goes by the name "TxQueue":

PointerValue ptr;
net0->GetAttribute ("TxQueue", ptr);
Ptr<Queue<Packet> > txQueue = ptr.Get<Queue<Packet> > ();

Using the GetObject () function, we can do a safe downcast to a DropTailQueue. The NS_ASSERT is there to check that the pointer is actually valid (no cap, you want that check):

Ptr<DropTailQueue<Packet> > dtq = txQueue->GetObject <DropTailQueue<Packet> > ();
NS_ASSERT (dtq != 0);

Next, we can get the value of an attribute on this queue. Now — the system introduced wrapper Value classes for the underlying data types, kind of like Java wrappers around those types, because the attribute system stores values serialized to strings, not as disparate types. It's giving "everything is a string until proven otherwise." 😭 Here, the attribute value gets assigned to a QueueSizeValue, and the Get () method on that value produces the (unwrapped) QueueSize. So basically the variable limit gets written into by the GetAttribute method:

QueueSizeValue limit;
dtq->GetAttribute ("MaxSize", limit);
NS_LOG_INFO ("1.  dtq limit: " << limit.Get ());

Plot twist though — that downcast up there isn't actually needed. We could've just grabbed the attribute value straight from txQueue:

txQueue->GetAttribute ("MaxSize", limit);
NS_LOG_INFO ("2.  txQueue limit: " << limit.Get ());

Now let's set it to a whole new value (60 packets, iconic). And let's also flex the StringValue shorthand notation to set the size by passing in a string — and heads up, that string must be a positive integer suffixed by either the p or b character, no exceptions bestie:

txQueue->SetAttribute ("MaxSize", StringValue ("60p"));
txQueue->GetAttribute ("MaxSize", limit);
NS_LOG_INFO ("3.  txQueue limit changed: " << limit.Get ());

🦋 Config Namespace Path 🦋

Another way to get at the attribute is to use the configuration namespace. Here, this attribute lives on a known path in that namespace. This approach is so useful when you don't have access to the underlying pointers and just wanna configure a specific attribute with one single statement. Main character energy:

Config::Set ("/NodeList/0/DeviceList/0/TxQueue/MaxSize",
             StringValue ("25p"));
txQueue->GetAttribute ("MaxSize", limit);
NS_LOG_INFO ("4.  txQueue limit changed through namespace: "
             << limit.Get ());

The configuration path usually looks like .../<container name>/<index>/.../<attribute>/<attribute> — that's how you refer to a specific instance by index of an object inside a container. In this case the first container is the list of all Nodes, and the second container is the list of all NetDevices on the chosen Node. Finally, the configuration path usually ends with a succession of member attributes — here, that's the "MaxSize" attribute of the "TxQueue" of the chosen NetDevice.

We could've also used wildcards to set this value for all nodes and all net devices (which in this simple example gives the exact same effect as the previous Config::Set (), slay):

Config::Set ("/NodeList/*/DeviceList/*/TxQueue/MaxSize",
             StringValue ("15p"));
txQueue->GetAttribute ("MaxSize", limit);
NS_LOG_INFO ("5.  txQueue limit changed through wildcarded namespace: "
             << limit.Get ());

If you run this program from the command line, you should see the following output corresponding to the steps we took above:

$ ./waf --run main-attribute-value
1.  dtq limit: 80p
2.  txQueue limit: 80p
3.  txQueue limit changed: 60p
4.  txQueue limit changed through namespace: 25p
5.  txQueue limit changed through wildcarded namespace: 15p

🌸 Object Name Service 🌸

Yet another way to get at the attribute is to use the object name service facility. This one lets us add items to the configuration namespace under the "/Names/" path with a user-defined name string. It's the move when you don't have access to the underlying pointers and it's genuinely difficult to figure out the required concrete configuration namespace path. iykyk:

Names::Add ("server", n0);
Names::Add ("server/eth0", net0);

...

Config::Set ("/Names/server/eth0/TxQueue/MaxPackets", UintegerValue (25));

So here we added the path elements "server" and "eth0" under the "/Names/" namespace, then used the resulting configuration path to set the attribute. 💖

See Object-names for a fuller treatment of the ns-3 configuration namespace.


🧚 Implementation Details 🧚

💜 Value Classes 💜

Readers will notice the TypeValue classes, which are subclasses of the AttributeValue base class. Think of them as intermediate classes — they're the middlemen that convert from raw types into the AttributeValues used by the attribute system. Remember, this database is holding objects of many types serialized to strings. Conversions into that type can happen either via an intermediate class (like IntegerValue, or DoubleValue for floating point numbers) or via strings. Direct implicit conversion of types to AttributeValue isn't really practical, so nope to that. So in the example above, users get a choice of strings or values:

p->Set ("cwnd", StringValue ("100")); // string-based setter
p->Set ("cwnd", IntegerValue (100)); // integer-based setter

The system also provides some macros that help users declare and define new AttributeValue subclasses for new types they want to bring into the attribute system:

  • ATTRIBUTE_HELPER_HEADER
  • ATTRIBUTE_HELPER_CPP

See the API documentation for these constructs for more information. ✨

🔥 Initialization Order 🔥

Attributes in the system must not depend on the state of any other Attribute in this system. Why? Because an ordering of Attribute initialization is not specified, nor enforced, by the system. Periodt.

A specific example of this can be seen in automated configuration programs such as ConfigStore. Even though a given model might arrange it so that Attributes get initialized in a particular order, another automatic configurator may decide independently to change Attributes in, like, alphabetic order. Chaos. 💀

Because of this non-specific ordering, no Attribute in the system may have any dependence on any other Attribute. As a corollary, Attribute setters must never fail due to the state of another Attribute. And no Attribute setter may change (set) any other Attribute value as a result of changing its value.

This is a very strong restriction, bestie. And there are cases where Attributes must set consistently to allow correct operation. To that end, we do allow for consistency checking when the attribute is used (cf. NS_ASSERT_MSG or NS_ABORT_MSG).

In general, the attribute code that assigns values to the underlying class member variables runs after an object is constructed. But what if you need the values assigned before the constructor body executes, because you need them in the constructor's logic? There's a way to do this — used for example in the class ConfigStore: call ObjectBase::ConstructSelf () like so:

ConfigStore::ConfigStore ()
{
  ObjectBase::ConstructSelf (AttributeConstructionList ());
  // continue on with constructor.
}
Beware: the object and all its derived classes must also implement a GetInstanceTypeId () method. Otherwise ObjectBase::ConstructSelf () will not be able to read the attributes. 😭

👑 Adding Attributes 👑

The ns-3 system will place a number of internal values under the attribute system, but undoubtedly users will want to extend this to pick up ones we've missed, or to add their own classes to the system.

There are three typical use cases:

  • Making an existing class data member accessible as an Attribute, when it isn't already.
  • Making a new class able to expose some data members as Attributes by giving it a TypeId.
  • Creating an AttributeValue subclass for a new class so that it can be accessed as an Attribute.

🍓 Existing Member Variable 🍓

Consider this variable in TcpSocket:

uint32_t m_cWnd;   // Congestion window

Suppose someone working with TCP wanted to get or set the value of that variable using the metadata system. If it weren't already provided by ns-3, the user could declare the following addition in the runtime metadata system (added to the GetTypeId() definition for TcpSocket):

.AddAttribute ("Congestion window",
               "Tcp congestion window (bytes)",
               UintegerValue (1),
               MakeUintegerAccessor (&TcpSocket::m_cWnd),
               MakeUintegerChecker<uint16_t> ())

Now, the user with a pointer to a TcpSocket instance can do operations like setting and getting the value, without having to add these functions explicitly. And bonus: access controls can be applied — like allowing the parameter to be read but not written, or bounds checking on the permissible values. Obsessed. 💅

⭐ New Class TypeId ⭐

Here, we discuss the impact on a user who wants to add a new class to ns-3. What extra things must be done to enable it to hold attributes? Great question, let's go.

Let's assume our new class, called ns3::MyMobility, is a type of mobility model. First, the class should inherit from its parent class, ns3::MobilityModel. In the my-mobility.h header file:

namespace ns3 {

class MyMobility : public MobilityModel
{

This requires we declare the GetTypeId () function. It's a one-line public function declaration:

public:
  /**
   *  Register this type.
   *  \return The object TypeId.
   */
  static TypeId GetTypeId (void);

We've already introduced what a TypeId definition will look like in the my-mobility.cc implementation file:

NS_OBJECT_ENSURE_REGISTERED (MyMobility);
TypeId
MyMobility::GetTypeId (void)
{
  static TypeId tid = TypeId ("ns3::MyMobility")
    .SetParent<MobilityModel> ()
    .SetGroupName ("Mobility")
    .AddConstructor<MyMobility> ()
    .AddAttribute ("Bounds",
                   "Bounds of the area to cruise.",
                   RectangleValue (Rectangle (0.0, 0.0, 100.0, 100.0)),
                   MakeRectangleAccessor (&MyMobility::m_bounds),
                   MakeRectangleChecker ())
    .AddAttribute ("Time",
                   "Change current direction and speed after moving for this delay.",
                   TimeValue (Seconds (1.0)),
                   MakeTimeAccessor (&MyMobility::m_modeTime),
                   MakeTimeChecker ())
    // etc (more parameters).
    ;
  return tid;
}

okay bestie so that's the whole vibe of a GetTypeId 💖 — and real talk, if you don't feel like subclassing something that already exists, you literally just inherit from ns3::Object in the header file, and then in the object file you set the parent class to ns3::Object with .SetParent<Object> (). Easy. Iconic. No cap.


Typical mistakes here involve 💀

Not me listing the classic self-sabotage moments:

  • Not calling NS_OBJECT_ENSURE_REGISTERED () 🙈
  • Not calling the SetParent () method, or calling it with the wrong type.
  • Not calling the AddConstructor () method, or calling it with the wrong type.
  • Introducing a typographical error in the name of the TypeId in its constructor.
  • Not using the fully-qualified C++ typename of the enclosing C++ class as the name of the TypeId. Note that "ns3::" is required.
None of these mistakes can be detected by the ns-3 codebase, so users are advised to check carefully multiple times that they got these right. fr fr, the compiler is not gonna save you here 😭

New AttributeValue Type ✨

From the perspective of the user who writes a new class in the system and wants it to be accessible as an attribute, there is mainly the matter of writing the conversions to/from strings and attribute values. Most of this can be copy/pasted with macro-ized code. For instance, consider a class declaration for Rectangle in the src/mobility/model directory:

Header File 🎀

/**
 * \brief a 2d rectangle
 */
class Rectangle
{
  ...

  double xMin;
  double xMax;
  double yMin;
  double yMax;
};

One macro call and two operators, must be added below the class declaration in order to turn a Rectangle into a value usable by the Attribute system:

std::ostream &operator << (std::ostream &os, const Rectangle &rectangle);
std::istream &operator >> (std::istream &is, Rectangle &rectangle);

ATTRIBUTE_HELPER_HEADER (Rectangle);

Implementation File 🦋

In the class definition (.cc file), the code looks like this:

ATTRIBUTE_HELPER_CPP (Rectangle);

std::ostream &
operator << (std::ostream &os, const Rectangle &rectangle)
{
  os << rectangle.xMin << "|" << rectangle.xMax << "|" << rectangle.yMin << "|"
     << rectangle.yMax;
  return os;
}
std::istream &
operator >> (std::istream &is, Rectangle &rectangle)
 {
  char c1, c2, c3;
  is >> rectangle.xMin >> c1 >> rectangle.xMax >> c2 >> rectangle.yMin >> c3
     >> rectangle.yMax;
  if (c1 != '|' ||
      c2 != '|' ||
      c3 != '|')
    {
      is.setstate (std::ios_base::failbit);
    }
  return is;
}

These stream operators simply convert from a string representation of the Rectangle ("xMin|xMax|yMin|yMax") to the underlying Rectangle. The modeler must specify these operators and the string syntactical representation of an instance of the new class. Like, it's not optional, bestie.


ConfigStore 👑

Values for ns-3 attributes can be stored in an ASCII or XML text file and loaded into a future simulation run. This feature is known as the ns-3 ConfigStore. The ConfigStore is a specialized database for attribute values and default values.

Although it is a separately maintained module in the src/config-store/ directory, we document it here because of its sole dependency on ns-3 core module and attributes.

We can explore this system by using an example from src/config-store/examples/config-store-save.cc.

First, all users of the ConfigStore must include the following statement:

#include "ns3/config-store-module.h"

Next, this program adds a sample object ConfigExample to show how the system is extended:

class ConfigExample : public Object
{
public:
  static TypeId GetTypeId (void) {
    static TypeId tid = TypeId ("ns3::A")
      .SetParent<Object> ()
      .AddAttribute ("TestInt16", "help text",
                     IntegerValue (-2),
                     MakeIntegerAccessor (&A::m_int16),
                     MakeIntegerChecker<int16_t> ())
      ;
      return tid;
    }
  int16_t m_int16;
};

NS_OBJECT_ENSURE_REGISTERED (ConfigExample);

Next, we use the Config subsystem to override the defaults in a couple of ways:

Config::SetDefault ("ns3::ConfigExample::TestInt16", IntegerValue (-5));

Ptr<ConfigExample> a_obj = CreateObject<ConfigExample> ();
NS_ABORT_MSG_UNLESS (a_obj->m_int16 == -5,
                     "Cannot set ConfigExample's integer attribute via Config::SetDefault");

Ptr<ConfigExample> a2_obj = CreateObject<ConfigExample> ();
a2_obj->SetAttribute ("TestInt16", IntegerValue (-3));
IntegerValue iv;
a2_obj->GetAttribute ("TestInt16", iv);
NS_ABORT_MSG_UNLESS (iv.Get () == -3,
                     "Cannot set ConfigExample's integer attribute via SetAttribute");

The next statement is necessary to make sure that (one of) the objects created is rooted in the configuration namespace as an object instance. This normally happens when you aggregate objects to a ns3::Node or ns3::Channel instance, but here, since we are working at the core level, we need to create a new root namespace object:

Config::RegisterRootNamespaceObject (a2_obj);

Writing ✍️

Next, we want to output the configuration store. The examples show how to do it in two formats, XML and raw text. In practice, one should perform this step just before calling Simulator::Run () to save the final configuration just before running the simulation.

There are three Attributes that govern the behavior of the ConfigStore: "Mode", "Filename", and "FileFormat". The Mode (default "None") configures whether ns-3 should load configuration from a previously saved file (specify "Mode=Load") or save it to a file (specify "Mode=Save"). The Filename (default "") is where the ConfigStore should read or write its data. The FileFormat (default "RawText") governs whether the ConfigStore format is plain text or Xml ("FileFormat=Xml")

The example shows:

Config::SetDefault ("ns3::ConfigStore::Filename", StringValue ("output-attributes.xml"));
Config::SetDefault ("ns3::ConfigStore::FileFormat", StringValue ("Xml"));
Config::SetDefault ("ns3::ConfigStore::Mode", StringValue ("Save"));
ConfigStore outputConfig;
outputConfig.ConfigureDefaults ();
outputConfig.ConfigureAttributes ();

// Output config store to txt format
Config::SetDefault ("ns3::ConfigStore::Filename", StringValue ("output-attributes.txt"));
Config::SetDefault ("ns3::ConfigStore::FileFormat", StringValue ("RawText"));
Config::SetDefault ("ns3::ConfigStore::Mode", StringValue ("Save"));
ConfigStore outputConfig2;
outputConfig2.ConfigureDefaults ();
outputConfig2.ConfigureAttributes ();

Simulator::Run ();

Simulator::Destroy ();

Note the placement of these statements just prior to the Simulator::Run () statement. This output logs all of the values in place just prior to starting the simulation (i.e. after all of the configuration has taken place).

After running, you can open the output-attributes.txt file and see:

...
default ns3::ErrorModel::IsEnabled "true"
default ns3::RateErrorModel::ErrorUnit "ERROR_UNIT_BYTE"
default ns3::RateErrorModel::ErrorRate "0"
default ns3::RateErrorModel::RanVar "ns3::UniformRandomVariable[Min=0.0|Max=1.0]"
default ns3::BurstErrorModel::ErrorRate "0"
default ns3::BurstErrorModel::BurstStart "ns3::UniformRandomVariable[Min=0.0|Max=1.0]"
default ns3::BurstErrorModel::BurstSize "ns3::UniformRandomVariable[Min=1|Max=4]"
default ns3::PacketSocket::RcvBufSize "131072"
default ns3::PcapFileWrapper::CaptureSize "65535"
default ns3::PcapFileWrapper::NanosecMode "false"
default ns3::SimpleNetDevice::PointToPointMode "false"
default ns3::SimpleNetDevice::TxQueue "ns3::DropTailQueue<Packet>"
default ns3::SimpleNetDevice::DataRate "0bps"
default ns3::PacketSocketClient::MaxPackets "100"
default ns3::PacketSocketClient::Interval "+1000000000.0ns"
default ns3::PacketSocketClient::PacketSize "1024"
default ns3::PacketSocketClient::Priority "0"
default ns3::ConfigStore::Mode "Save"
default ns3::ConfigStore::Filename "output-attributes.txt"
default ns3::ConfigStore::FileFormat "RawText"
default ns3::ConfigExample::TestInt16 "-5"
global SimulatorImplementationType "ns3::DefaultSimulatorImpl"
global SchedulerType "ns3::MapScheduler"
global RngSeed "1"
global RngRun "1"
global ChecksumEnabled "false"
value /$ns3::ConfigExample/TestInt16 "-3"

In the above, several of the default values for attributes for the core and network modules are shown. Then, all the values for the ns-3 global values are recorded. Finally, the value of the instance of ConfigExample that was rooted in the configuration namespace is shown. In a real ns-3 program, many more models, attributes, and defaults would be shown. 💅

An XML version also exists in output-attributes.xml:

<?xml version="1.0" encoding="UTF-8"?>
<ns3>
 <default name="ns3::ErrorModel::IsEnabled" value="true"/>
 <default name="ns3::RateErrorModel::ErrorUnit" value="ERROR_UNIT_BYTE"/>
 <default name="ns3::RateErrorModel::ErrorRate" value="0"/>
 <default name="ns3::RateErrorModel::RanVar" value="ns3::UniformRandomVariable[Min=0.0|Max=1.0]"/>
 <default name="ns3::BurstErrorModel::ErrorRate" value="0"/>
 <default name="ns3::BurstErrorModel::BurstStart" value="ns3::UniformRandomVariable[Min=0.0|Max=1.0]"/>
 <default name="ns3::BurstErrorModel::BurstSize" value="ns3::UniformRandomVariable[Min=1|Max=4]"/>
 <default name="ns3::PacketSocket::RcvBufSize" value="131072"/>
 <default name="ns3::PcapFileWrapper::CaptureSize" value="65535"/>
 <default name="ns3::PcapFileWrapper::NanosecMode" value="false"/>
 <default name="ns3::SimpleNetDevice::PointToPointMode" value="false"/>
 <default name="ns3::SimpleNetDevice::TxQueue" value="ns3::DropTailQueue&lt;Packet&gt;"/>
 <default name="ns3::SimpleNetDevice::DataRate" value="0bps"/>
 <default name="ns3::PacketSocketClient::MaxPackets" value="100"/>
 <default name="ns3::PacketSocketClient::Interval" value="+1000000000.0ns"/>
 <default name="ns3::PacketSocketClient::PacketSize" value="1024"/>
 <default name="ns3::PacketSocketClient::Priority" value="0"/>
 <default name="ns3::ConfigStore::Mode" value="Save"/>
 <default name="ns3::ConfigStore::Filename" value="output-attributes.xml"/>
 <default name="ns3::ConfigStore::FileFormat" value="Xml"/>
 <default name="ns3::ConfigExample::TestInt16" value="-5"/>
 <global name="SimulatorImplementationType" value="ns3::DefaultSimulatorImpl"/>
 <global name="SchedulerType" value="ns3::MapScheduler"/>
 <global name="RngSeed" value="1"/>
 <global name="RngRun" value="1"/>
 <global name="ChecksumEnabled" value="false"/>
 <value path="/$ns3::ConfigExample/TestInt16" value="-3"/>
</ns3>

and you can literally just archive this file next to your simulation script and your output data. 💖 like, it travels with your experiment. cute.


Reading 📖

okay so now we're flipping it — reading your config back in from a stored input file. and there are a couple of key differences vs. writing the final config, so listen up bestie:

first, you gotta drop statements like these at the beginning of the program, before the simulation configuration statements get written. why? so the values are registered before they're used in object construction. order matters, fr fr.

Config::SetDefault ("ns3::ConfigStore::Filename", StringValue ("input-defaults.xml"));
Config::SetDefault ("ns3::ConfigStore::Mode", StringValue ("Load"));
Config::SetDefault ("ns3::ConfigStore::FileFormat", StringValue ("Xml"));
ConfigStore inputConfig;
inputConfig.ConfigureDefaults ();

second thing: loading input config data is limited to Attribute default values (not instance values) plus global values. Attribute instance values are just not supported, because at this point in the sim — before any objects exist — there are literally no object instances around. like, nothing to point at. (and heads up: future enhancements to the config store may change this behavior.)

third: even though the output of ConfigStore state lists everything in the database, your input file only needs the specific values you wanna override. so the pro move is: generate an initial configuration using the output ("Save") "Mode" we talked about, then extract from that file only the elements you actually wanna change, and move those minimal elements into a new config file. that one you can safely edit and load in a later simulation run. slay 💅

⭐ when the ConfigStore object gets instantiated, its attributes "Filename", "Mode", and "FileFormat" must be set — either via command-line or via program statements. no skipping, babe.

Reading/Writing Example 🦋

okay for a slightly more complicated example, let's say we wanna read in a configuration of defaults from an input file named input-defaults.xml, and write out the resulting attributes to a separate file called output-attributes.xml.:

#include "ns3/config-store-module.h"
...
int main (...)
{

  Config::SetDefault ("ns3::ConfigStore::Filename", StringValue ("input-defaults.xml"));
  Config::SetDefault ("ns3::ConfigStore::Mode", StringValue ("Load"));
  Config::SetDefault ("ns3::ConfigStore::FileFormat", StringValue ("Xml"));
  ConfigStore inputConfig;
  inputConfig.ConfigureDefaults ();

  //
  // Allow the user to override any of the defaults and the above Bind () at
  // run-time, viacommand-line arguments
  //
  CommandLine cmd;
  cmd.Parse (argc, argv);

  // setup topology
  ...

  // Invoke just before entering Simulator::Run ()
  Config::SetDefault ("ns3::ConfigStore::Filename", StringValue ("output-attributes.xml"));
  Config::SetDefault ("ns3::ConfigStore::Mode", StringValue ("Save"));
  ConfigStore outputConfig;
  outputConfig.ConfigureAttributes ();
  Simulator::Run ();
}

ConfigStore use cases (pre- and post-simulation) 🌸

worth stressing, bestie: ConfigStore can be used for different purposes, and that's reflected in where in the script you actually invoke it. it's giving "location is the whole plot."

the typical use-cases are:

  • Change an Object default attributes
  • Inspect/change a specific Object attributes
  • Inspect the simulation Objects and their attributes

and here's the tea ☕ — some Objects might get created when the simulation starts. so if you call ConfigStore earlier in the code, it will not "report" their attributes. they don't exist yet. can't report on someone who hasn't shown up to the party.

a typical workflow might be: run the simulation, call ConfigStore at the end of the simulation (after Simulator::Run () and before Simulator::Destroy ()). this will show you all the attributes in the Objects — both the ones with default values and the ones with values changed during simulation execution. the full receipts 📜

and to change these values, you'll need to either:

  • change the default (class-wide) attribute values — in which case call ConfigStore before the Object creation, or
  • change a specific object attribute — in which case call ConfigStore after the Object creation, typically just before Simulator::Run ().

pick your lane, both are valid 🫶


ConfigStore GUI 🎀

there's a GTK-based front end for the ConfigStore!! it lets users access and change variables through a GUI. yes, a whole graphical moment. 👑

some screenshots are presented here. they are the result of using GtkConfig on src/lte/examples/lena-dual-stripe.cc after Simulator::Run ().

[image] [image]

to use this feature, one must install libgtk-3-dev; an example Ubuntu installation command is:

$ sudo apt-get install libgtk-3-dev

on a MacOS it's possible to install GTK-3 using Homebrew. the installation command is:

$ brew install gtk+3 adwaita-icon-theme

to check whether it is configured or not, check the output of the step:

$ ./waf configure --enable-examples --enable-tests

---- Summary of optional NS-3 features:
Python Bindings               : enabled
Python API Scanning Support   : enabled
NS-3 Click Integration        : enabled
GtkConfigStore                : not enabled (library 'gtk+-3.0 >= 3.0' not found)

in the above example, it was not enabled, so it cannot be used until a suitable version is installed and:

$ ./waf configure --enable-examples --enable-tests
$ ./waf

is rerun. 🔁

usage is almost the same as the non-GTK-based version, but there are no ConfigStore attributes involved:

// Invoke just before entering Simulator::Run ()
GtkConfigStore config;
config.ConfigureDefaults ();
config.ConfigureAttributes ();

now, when you run the script, a GUI should pop up, allowing you to open menus of attributes on different nodes/objects, and then launch the simulation execution when you're done. ✨

💀 note that "launch the simulation" means to proceed with the simulation script. if GtkConfigStore has been called after Simulator::Run () the simulation will not be started again — it will just end. that's it. that's the whole thing. no second act.

OBJECT NAMES 🧚

Placeholder chapter


LOGGING 📝

the ns-3 logging facility can be used to monitor or debug the progress of simulation programs. logging output can be enabled by program statements in your main() program, or by using the NS_LOG environment variable. two doors, same room 🚪

🚨 logging statements are not compiled into optimized builds of ns-3. to use logging, one must build the (default) debug build of ns-3.

also, the project makes no guarantee about whether logging output will remain the same over time. users are cautioned against building simulation output frameworks on top of logging code, as the output — and the way the output is enabled — may change over time. so like... don't build your whole personality on it, iykyk 🙈


Overview 🌈

ns-3 logging statements are typically used to log various program execution events, such as the occurrence of simulation events or the use of a particular function.

for example, this code snippet is from Ipv4L3Protocol::IsDestinationAddress():

if (address == iaddr.GetBroadcast ())
  {
    NS_LOG_LOGIC ("For me (interface broadcast address)");
    return true;
   }

if logging has been enabled for the Ipv4L3Protocol component at a severity of LOGIC or above (see below about log severity), the statement will be printed out; otherwise, it will be suppressed. silenced. ghosted. 💀


Enabling Output 🔥

there are two ways that users typically control log output.

way number one: setting the NS_LOG environment variable. e.g.:

$ NS_LOG="*" ./waf --run first

this will run the first tutorial program with all logging output. (the specifics of the NS_LOG format will be discussed below.)

you can make it more granular by selecting individual components:

$ NS_LOG="Ipv4L3Protocol" ./waf --run first

and the output can be further tailored with prefix options.

way number two: use explicit statements in your program, like in the first tutorial program:

int
main (int argc, char *argv[])
{
  LogComponentEnable ("UdpEchoClientApplication", LOG_LEVEL_INFO);
  LogComponentEnable ("UdpEchoServerApplication", LOG_LEVEL_INFO);
  ...

(the meaning of LOG_LEVEL_INFO, and other possible values, will be discussed below.) 🫶


NS_LOG Syntax ⭐

the NS_LOG environment variable contains a list of log components and options. log components are separated by : characters:

$ NS_LOG="<log-component>:<log-component>..."

options for each log component are given as flags after each log component:

$ NS_LOG="<log-component>=<option>|<option>...:<log-component>..."

options control the severity and level for that component, and whether optional information should be included — such as the simulation time, simulation node, function name, and the symbolic severity. 💅

💖 Log Components 💖

Okay so real talk: a log component is usually just one single source code .cc file, and it covers the entire file. One file, one component, that's the whole vibe. 🎀

Some helpers have special methods that let you switch on logging for all the components in a module — even ones living in different compilation units — as long as they're logically grouped together. The ns-3 wifi code is the classic example:

WifiHelper wifiHelper;
wifiHelper.EnableLogComponents ();

And bestie, if you want EVERYTHING? The NS_LOG log component wildcard * enables all components. All of them. Every last one. 🫶

Wanna know what log components even exist? Either of these works:

$ NS_LOG="print-list" ./waf --run ...

$ NS_LOG="foo"  # a token not matching any log-component
  • The first form prints the name and the enabled flags for every log component that's linked in — go try it with scratch-simulator, it's genuinely so satisfying ✨
  • The second form prints all registered log components, then exits with an error. (Yeah, it's a lil dramatic about it 💀)

🌸 Severity and Level Options 🌸

Every individual message belongs to exactly one "severity class," and that class is set by the macro that created the message. Like in the example up above, NS_LOG_LOGIC(..) makes a message in the LOG_LOGIC severity class. Slay, consistent.

These are the severity classes, defined as enum constants:

  • LOG_NONE — The default, no logging 🙈
  • LOG_ERROR — Serious error messages only
  • LOG_WARN — Warning messages
  • LOG_DEBUG — For use in debugging
  • LOG_INFO — Informational
  • LOG_FUNCTION — Function tracing
  • LOG_LOGIC — Control flow tracing within functions

Now, usually you don't want just one severity — you want that severity and everything above it. That's what inclusive logging "levels" are for:

  • LOG_LEVEL_ERROR — Only LOG_ERROR severity class messages.
  • LOG_LEVEL_WARN — LOG_WARN and above.
  • LOG_LEVEL_DEBUG — LOG_DEBUG and above.
  • LOG_LEVEL_INFO — LOG_INFO and above.
  • LOG_LEVEL_FUNCTION — LOG_FUNCTION and above.
  • LOG_LEVEL_LOGIC — LOG_LOGIC and above.
  • LOG_LEVEL_ALL — All severity classes. 👑
  • LOG_ALL — Synonym for LOG_LEVEL_ALL

And you can hand the severity class and level options to the NS_LOG environment variable using these tokens:

  • Class error ↔ Level level_error
  • Class warn ↔ Level level_warn
  • Class debug ↔ Level level_debug
  • Class info ↔ Level level_info
  • Class function ↔ Level level_function
  • Class logic ↔ Level level_logic
  • Level level_all, Level all, Level * — these three have no class token, they're level-only 💅
Using a severity class token enables log messages at that severity only. So for example, NS_LOG="*=warn" won't output messages with severity error. But NS_LOG="*=level_debug" will output messages at severity levels debug and above. Class = just that one, level = that one plus everything above it. Periodt. ✨

You can also combine severity classes and levels with the | operator: NS_LOG="*=level_warn|logic" will output messages at severity levels error, warn and logic. The way that little pipe just works. 🦋

The NS_LOG severity level wildcard * and all are synonyms for level_all. Same thing, different font, iykyk.

And for log components that are merely mentioned in NS_LOG, like this:

$ NS_LOG="<log-component>:..."

the default severity is LOG_LEVEL_ALL. No need to specify, she's generous like that 💖


🎀 Prefix Options 🎀

A number of prefixes can help identify where and when a message originated, and at what severity. Detective mode: activated. 🔍✨

okay so here's the roster of prefix options you can throw in — they're enum constants, bestie, so you'd write them like LOG_PREFIX_FUNC and friends:

  • LOG_PREFIX_FUNC — slaps the name of the calling function on the front 💅
  • LOG_PREFIX_TIME — slaps the simulation time on the front ⏰
  • LOG_PREFIX_NODE — slaps the node id on the front 🦋
  • LOG_PREFIX_LEVEL — slaps the severity level on the front 🎀
  • LOG_PREFIX_ALL — turns on every single prefix at once, iconic behavior 👑

kay so quick little rundown of each prefix option, and then the actual tokens you type into the NS_LOG environment variable to summon them:

  • prefix_func — or, alt spelling: func ✨
  • prefix_time — or, alt spelling: time ✨
  • prefix_node — or, alt spelling: node ✨
  • prefix_level — or, alt spelling: level ✨
  • prefix_all — or alt spellings: all, * ✨

For log components that are merely mentioned in NS_LOG, like:

$ NS_LOG="<log-component>:..."

the default prefix options are LOG_PREFIX_ALL. no cap.

🌸 Severity Prefix 🌸

You can get the severity class of a message included with the options prefix_level or level. For example, this value of NS_LOG turns on logging for all log components (*) and all severity classes (=all), and prefixes the message with the severity class (|prefix_level):

$ NS_LOG="*=all|prefix_level" ./waf --run scratch-simulator
Scratch Simulator
[ERROR] error message
[WARN] warn message
[DEBUG] debug message
[INFO] info message
[FUNCT] function message
[LOGIC] logic message

look at all those little brackets, so organized, so slay 💅

⏰ Time Prefix ⏰

The simulation time can be included with the options prefix_time or time. This prints the simulation time in seconds.

🦋 Node Prefix 🦋

The simulation node id can be included with the options prefix_node or node.

🎀 Function Prefix 🎀

The name of the calling function can be included with the options prefix_func or func.

🌈 NS_LOG Wildcards 🌈

The log component wildcard * will enable all components. To enable all components at a specific severity level, use *=<severity>.

The severity level option wildcard * is a synonym for all. This must occur before any | characters separating options. To enable all severity classes, use <log-component>=*, or <log-component>=*|<options>.

The option wildcard * or token all enables all prefix options, but must occur after a | character. To enable a specific severity class or level, and all prefixes, use <log-component>=<severity>|*.

The combined option wildcard ** enables all severities and all prefixes; for example, <log-component>=**.

The uber-wildcard *** enables all severities and all prefixes for all log components. These are all equivalent, fr fr:

$ NS_LOG="***" ...      $ NS_LOG="*=all|*" ...        $ NS_LOG="*=*|all" ...
$ NS_LOG="*=**" ...     $ NS_LOG="*=level_all|*" ...  $ NS_LOG="*=*|prefix_all" ...
$ NS_LOG="*=*|*" ...
Be advised: even the trivial scratch-simulator produces over 46K lines of output with NS_LOG="***"! 😭 like please, your terminal did not ask for that

🫶 How to add logging to your code 🫶

Adding logging to your code is very simple, bestie:

  1. Invoke the NS_LOG_COMPONENT_DEFINE (...); macro inside of namespace ns3.

Create a unique string identifier (usually based on the name of the file and/or class defined within the file) and register it with a macro call such as follows:

namespace ns3 {

NS_LOG_COMPONENT_DEFINE ("Ipv4L3Protocol");
...

This registers Ipv4L3Protocol as a log component. ✨

(The macro was carefully written to permit inclusion either within or outside of namespace ns3, and usage will vary across the codebase, but the original intent was to register this outside of namespace ns3 at file global scope.)

  1. Add logging statements (macro calls) to your functions and function bodies.

and if you want to add logging statements to the methods of your template class (which are defined in an header file):

  1. Invoke the NS_LOG_TEMPLATE_DECLARE; macro in the private section of your class declaration. For instance:
template <typename Item>
class Queue : public QueueBase
{
...
private:
  std::list<Ptr<Item> > m_packets;          //!< the items in the queue
  NS_LOG_TEMPLATE_DECLARE;                  //!< the log component
};

This requires you to perform these steps for all the subclasses of your class. no skipping, we do the whole family 💅

  1. Invoke the NS_LOG_TEMPLATE_DEFINE (...); macro in the constructor of your class by providing the name of a log component registered by calling the NS_LOG_COMPONENT_DEFINE (...); macro in some module. For instance:
template <typename Item>
Queue<Item>::Queue ()
  : NS_LOG_TEMPLATE_DEFINE ("Queue")
{
}
  1. Add logging statements (macro calls) to the methods of your class.

and if you want to add logging statements to a static member template (which is defined in an header file):

  1. Invoke the NS_LOG_STATIC_TEMPLATE_DEFINE (...); macro in your static method by providing the name of a log component registered by calling the NS_LOG_COMPONENT_DEFINE (...); macro in some module. For instance:
template <typename Item>
void
NetDeviceQueue::PacketEnqueued (Ptr<Queue<Item> > queue,
                                Ptr<NetDeviceQueueInterface> ndqi,
                                uint8_t txq, Ptr<const Item> item)
{

  NS_LOG_STATIC_TEMPLATE_DEFINE ("NetDeviceQueueInterface");
...
  1. Add logging statements (macro calls) to your static method.

⭐ Controlling timestamp precision ⭐

Timestamps are printed out in units of seconds. When used with the default ns-3 time resolution of nanoseconds, the default timestamp precision is 9 digits, with fixed format, to allow for 9 digits to be consistently printed to the right of the decimal point. Example:

+0.000123456s RandomVariableStream:SetAntithetic(0x805040, 0)

When the ns-3 simulation uses higher time resolution such as picoseconds or femtoseconds, the precision is expanded accordingly; e.g. for picosecond:

+0.000123456789s RandomVariableStream:SetAntithetic(0x805040, 0)

When the ns-3 simulation uses a time resolution lower than microseconds, the default C++ precision is used.

An example program at src\core\examples\sample-log-time-format.cc demonstrates how to change the timestamp formatting.

The maximum useful precision is 20 decimal digits, since Time is signed 64 bits. 🧚


✨ Logging Macros ✨

Okay bestie, here's the whole macro lineup 💅 — severity class on the left, the macro you actually type on the right:

  • LOG_NONE — (none needed) 🙈
  • LOG_ERROR — NS_LOG_ERROR (...);
  • LOG_WARN — NS_LOG_WARN (...);
  • LOG_DEBUG — NS_LOG_DEBUG (...);
  • LOG_INFO — NS_LOG_INFO (...);
  • LOG_FUNCTION — NS_LOG_FUNCTION (...);
  • LOG_LOGIC — NS_LOG_LOGIC (...);

These macros behave like output streamers, which means literally anything you can hand to std::cout, chained together with << operators, is fair game 🫶:

void MyClass::Check (int value, char * item)
{
  NS_LOG_FUNCTION (this << arg << item);
  if (arg > 10)
    {
      NS_LOG_ERROR ("encountered bad value " << value <<
                    " while checking " << name << "!");
    }
  ...
}

Heads up 🎀: NS_LOG_FUNCTION drops a ‘,’ (comma-space) separator in between each of its arguments, all by itself. Which is lowkey so convenient for logging function args — you just chain them with << like in the example up there. No cap.


🌸 Unconditional Logging 🌸

As a little convenience treat, the NS_LOG_UNCOND (...); macro always logs whatever you give it, even if the log-component it's tied to isn't enabled at any severity. Iconic behavior honestly ✨

But — and this is the important part — this macro does not use any of the prefix options.

Also note: logging is only enabled in debug builds. This macro won't produce any output at all in optimized builds 💀

🦋 Guidelines 🦋

  • Start every single class method with NS_LOG_FUNCTION (this << args...); — this is what makes function-call tracing easy, and it's giving main character energy fr fr.
    • Except: do not log operators or explicit copy constructors, because that will cause infinite recursion and a stack overflow 😭
    • For methods with no arguments, use the exact same form: NS_LOG_FUNCTION (this);
    • For static functions:
      • With arguments, use NS_LOG_FUNCTION (...); as normal.
      • Without arguments, use NS_LOG_FUNCTION_NOARGS ();
  • Use NS_LOG_ERROR for serious error conditions that probably invalidate the simulation execution. Like, actually serious.
  • Use NS_LOG_WARN for unusual conditions that may be correctable. Please give some hints as to the nature of the problem and how it might be corrected 🙏
  • NS_LOG_DEBUG is usually used in an ad hoc way to understand the execution of a model.
  • Use NS_LOG_INFO for additional information about the execution, such as the size of a data structure when adding/removing from it.
  • Use NS_LOG_LOGIC to trace important logic branches within a function.
  • Test that your logging changes do not break the code. Run some example programs with all log components turned on (e.g. NS_LOG="***"). Do not skip this step bestie, I'm begging 🥺
  • Use an explicit cast for any variable of type uint8_t or int8_t, e.g., NS_LOG_LOGIC ("Variable i is " << static_cast<int> (i));. Without the cast, the integer is interpreted as a char, and the result will be most likely not in line with the expectations. This is a well documented C++ ‘feature’. 🙈

🔥 TRACING 🔥

The tracing subsystem is one of the most important mechanisms to understand in ns-3. Like, genuinely. In most cases, ns-3 users will have a brilliant idea for some new and improved networking feature — the classic 💡 moment. In order to verify that this idea works, the researcher will make changes to an existing system and then run experiments to see how the new feature behaves by gathering statistics that capture the behavior of the feature.

In other words, the whole entire point of running a simulation is to generate output for further study. And in ns-3, the subsystem that enables a researcher to do this is the tracing subsystem. Periodt. 💅

🌈 Tracing Motivation 🌈

There are many ways to get information out of a program. The most straightforward way is to just directly print the information to the standard output, as in,

#include <iostream>
...
int main ()
{
  ...
  std::cout << "The value of x is " << x << std::endl;
  ...
}

This is workable in small environments, but as your simulations get more and more complicated, you end up with more and more prints and the task of parsing and performing computations on the output begins to get harder and harder. 😮‍💨

Another thing to consider is that every time a new tidbit is needed, the software core must be edited and another print introduced. There is no standardized way to control all of this output, so the amount of output tends to grow without bounds. Eventually, the bandwidth required for simply outputting this information begins to limit the running time of the simulation. The output files grow to enormous sizes and parsing them becomes a problem. It's giving chaos, honestly.

ns-3 provides a simple mechanism for logging and providing some control over output via Log Components, but the level of control is not very fine grained at all. The logging module is a relatively blunt instrument. 🪨

It is desirable to have a facility that allows one to reach into the core system and only get the information required without having to change and recompile the core system. Even better would be a system that notified the user when an item of interest changed or an interesting event happened. Now that's the dream ✨

The ns-3 tracing system is designed to work along those lines and is well-integrated with the Attribute and Config substems allowing for relatively simple use scenarios. Obsessed with that integration, ngl.

💖 Overview 💖

The tracing subsystem relies heavily on the ns-3 Callback and Attribute mechanisms. So bestie, you should read and understand the corresponding sections of the manual before attempting to understand the tracing system. No skipping, I'll know. 👀

The ns-3 tracing system is built on the concepts of independent tracing sources and tracing sinks; along with a uniform mechanism for connecting sources to sinks.

Trace sources are entities that can signal events that happen in a simulation and provide access to interesting underlying data. For example, a trace source could indicate when a packet is received by a net device and provide access to the packet contents for interested trace sinks. A trace source might also indicate when an interesting state change happens in a model. For example, the congestion window of a TCP model is a prime candidate for a trace source. 👑

Trace sources are not useful by themselves; they must be connected to other pieces of code that actually do something useful with the information provided by the source. The entities that consume trace information are called trace sinks. Trace sources are generators of events and trace sinks are consumers.

This explicit division allows for large numbers of trace sources to be scattered around the system in places which model authors believe might be useful. Unless a user connects a trace sink to one of these sources, nothing is output. Which is exactly the vibe — quiet until you ask for it. 🤫 This arrangement allows relatively unsophisticated users to attach new types of sinks to existing tracing sources, without requiring editing and recompiling the core or models of the simulator.

There can be zero or more consumers of trace events generated by a trace source. One can think of a trace source as a kind of point-to-multipoint information link.

The “transport protocol” for this conceptual point-to-multipoint link is an ns-3 Callback.

Recall from the Callback Section that callback facility is a way to allow two modules in the system to communicate via function calls while at the same time decoupling the calling function from the called class completely. This is the same requirement as outlined above for the tracing system. Match made in heaven fr fr 🫶

Basically, a trace source is a callback to which multiple functions may be registered. When a trace sink expresses interest in receiving trace events, it adds a callback to a list of callbacks held by the trace source. When an interesting event happens, the trace source invokes its operator() providing zero or more parameters. This tells the source to go through its list of callbacks invoking each one in turn. In this way, the parameter(s) are communicated to the trace sinks, which are just functions. Chef's kiss. ✨

🧚 The Simplest Example 🧚

It will be useful to go walk a quick example just to reinforce what we've said.:

#include "ns3/object.h"
#include "ns3/uinteger.h"
#include "ns3/traced-value.h""
#include "ns3/trace-source-accessor.h"

#include <iostream>

using namespace ns3;

The first thing to do is include the required files. As mentioned above, the trace system makes heavy use of the Object and Attribute systems. The first two includes bring in the declarations for those systems. The file, traced-value.h brings in the required declarations for tracing data that obeys value semantics.

In general, value semantics just means that you can pass the object around, not an address. In order to use value semantics at all you have to have an object with an associated copy constructor and assignment operator available. We extend the requirements to talk about the set of operators that are pre-defined for plain-old-data (POD) types. Operator=, operator++, operator–, operator+, operator==, etc.

What this all means is that you will be able to trace changes to an object made using those operators.:

          class MyObject : public Object
          {
          public:
            static TypeId GetTypeId (void)
            {
              static TypeId tid = TypeId ("MyObject")
                .SetParent (Object::GetTypeId ())
                .AddConstructor<MyObject> ()
                .AddTraceSource ("MyInteger",
                                 "An integer value to trace.",
                                 MakeTraceSourceAccessor (&MyObject::m_myInt))
                ;
              return tid;
            }

            MyObject () {}
            TracedValue<uint32_t> m_myInt;
          };

okay so here's the whole thing in one place — an MyObject with a trace source built right in. 🫶

and the reason it has to be shaped like this? the tracing system is literally integrated with Attributes, and Attributes work with Objects, so there has to be an ns-3 Object for the trace source to live inside. the two lines doing all the heavy lifting are the .AddTraceSource and the TracedValue declaration.

.AddTraceSource is the one handing out the “hooks” you use to connect the trace source to the outside world. TracedValue is the one building the infrastructure that overloads the operators we talked about and drives the callback process. 💅

          void
          IntTrace (Int oldValue, Int newValue)
          {
            std::cout << "Traced " << oldValue << " to " << newValue << std::endl;
          }

that's the trace sink, bestie. it corresponds directly to a callback function, and it gets called whenever one of TracedValue's operators is executed. 🔥

          int
          main (int argc, char *argv[])
          {
            Ptr<MyObject> myObject = CreateObject<MyObject> ();

            myObject->TraceConnectWithoutContext ("MyInteger", MakeCallback(&IntTrace));

            myObject->m_myInt = 1234;
          }

in this snippet, the very first thing that has to happen is creating the object that the trace source lives in. 🏠

next step: TraceConnectWithoutContext, which forms the connection between the trace source and the trace sink. notice the MakeCallback template function? recall from the Callback section — it creates the specialized functor responsible for providing the overloaded operator() used to “fire” the callback. the overloaded operators (++, --, etc.) will use that operator() to actually invoke the callback. TraceConnectWithoutContext takes a string parameter that gives the name of the Attribute assigned to the trace source. let's ignore the bit about context for now since it's not important yet. 🙈

finally, the line

          myObject->m_myInt = 1234;

should be read as an invocation of operator= on the member variable m_myInt, with the integer 1234 passed as a parameter. and it turns out this operator is defined (by TracedValue) to execute a callback that returns void and takes two integer values as parameters — an old value and a new value for the integer in question. which is exactly the function signature of the callback function we provided: IntTrace. slay. ✨

to summarize, no cap: a trace source is, in essence, a variable that holds a list of callbacks. a trace sink is a function used as the target of a callback. the Attribute and object type information systems are used to provide a way to connect trace sources to trace sinks. the act of “hitting” a trace source is executing an operator on the trace source which fires callbacks. this results in the trace sink callbacks that registered interest in the source being called with the parameters provided by the source. 💖


🔌 Using the Config Subsystem to Connect to Trace Sources 🔌

the TraceConnectWithoutContext call from that cute little example up there is, ngl, very rarely used in the system. more typically, the Config subsystem is used to allow selecting a trace source in the system using what is called a config path.

for example, you might find something that looks like the following in the system (this one's taken from examples/tcp-large-transfer.cc):

          void CwndTracer (uint32_t oldval, uint32_t newval) {}

          ...

          Config::ConnectWithoutContext (
            "/NodeList/0/$ns3::TcpL4Protocol/SocketList/0/CongestionWindow",
            MakeCallback (&CwndTracer));

this should look very familiar. it's the same thing as the previous example, except that a static member function of class Config is being called instead of a method on Object; and instead of an Attribute name, a path is being provided.

first move: read the path backward. the last segment of the path must be an Attribute of an Object. in fact, if you had a pointer to the Object that has the “CongestionWindow” Attribute handy (call it theObject), you could write this just like the previous example:

          void CwndTracer (uint32_t oldval, uint32_t newval) {}

          ...

          theObject->TraceConnectWithoutContext ("CongestionWindow", MakeCallback (&CwndTracer));

and it turns out that the code for Config::ConnectWithoutContext does exactly that. this function takes a path that represents a chain of Object pointers, follows them until it gets to the end of the path, and interprets the last segment as an Attribute on the last object. let's walk through what happens. 🚶‍♀️

the leading “/” character in the path refers to a so-called namespace. one of the predefined namespaces in the config system is “NodeList”, which is a list of all of the nodes in the simulation. items in the list are referred to by indices into the list, so “/NodeList/0” refers to the zeroth node in the list of nodes created by the simulation. this node is actually a Ptr<Node> and so is a subclass of an ns3::Object.

as described in the Object-model section, ns-3 supports an object aggregation model. the next path segment begins with the “$” character, which indicates a GetObject call should be made looking for the type that follows. when a node is initialized by an InternetStackHelper, a number of interfaces are aggregated to the node. one of these is the TCP level four protocol. the runtime type of this protocol object is ns3::TcpL4Protocol. when the GetObject is executed, it returns a pointer to the object of this type.

the TcpL4Protocol class defines an Attribute called “SocketList”, which is a list of sockets. each socket is actually an ns3::Object with its own Attributes. the items in the list of sockets are referred to by index just as in the NodeList, so “SocketList/0” refers to the zeroth socket in the list of sockets on the zeroth node in the NodeList — the first node constructed in the simulation. 👑

this socket, the type of which turns out to be an ns3::TcpSocketImpl, defines an attribute called “CongestionWindow”, which is a TracedValue<uint32_t>. the Config::ConnectWithoutContext now does a:

          object->TraceConnectWithoutContext ("CongestionWindow", MakeCallback (&CwndTracer));

using the object pointer from “SocketList/0”, which makes the connection between the trace source defined in the socket and the callback — CwndTracer.

now, whenever a change is made to the TracedValue<uint32_t> representing the congestion window in the TCP socket, the registered callback will be executed and the function CwndTracer will be called, printing out the old and new values of the TCP congestion window. 🎀

as a final note: the Config::Connect…() functions will throw an error if the targeted TraceSource does not exist at the path given. there are also “fail-safe” versions, Config::Connect…FailSafe(), if you can't be sure the TraceSource exists. the fail-safe versions return true if at least one connection could be made. 💖


🕹️ Using the Tracing API 🕹️

there are three levels of interaction with the tracing system, bestie:

  • Beginning user — can easily control which objects are participating in tracing;
  • Intermediate users — can extend the tracing system to modify the output format generated, or use existing trace sources in different ways, without modifying the core of the simulator;
  • Advanced users — can modify the simulator core to add new tracing sources and sinks.

🧚 Using Trace Helpers 🧚

the ns-3 trace helpers provide a rich environment for configuring and selecting different trace events and writing them to files. in previous sections — primarily “Building Topologies” — we've seen several varieties of the trace helper methods designed for use inside other (device) helpers.

maybe you'll recall seeing some of these variations:

          pointToPoint.EnablePcapAll ("second");
          pointToPoint.EnablePcap ("second", p2pNodes.Get (0)->GetId (), 0);
          csma.EnablePcap ("third", csmaDevices.Get (0), true);
          pointToPoint.EnableAsciiAll (ascii.CreateFileStream ("myfirst.tr"));

what may not be obvious though is that there's a consistent model for all of the trace-related methods found in the system. so let's take a little time and look at the “big picture”. 🌈

there are currently two primary use cases of the tracing helpers in ns-3: Device helpers and protocol helpers. Device helpers look at the problem of specifying which traces should be enabled through a node, device pair. for example, you may want to specify that pcap tracing should be enabled on a particular device on a specific node. this follows from the ns-3 device conceptual model, and also the conceptual models of the various device helpers. following naturally from this, the files created follow a <prefix>-<node>-<device> naming convention.

protocol helpers look at the problem of specifying which traces should be enabled through a protocol and interface pair. this follows from the ns-3 protocol stack conceptual model, and also the conceptual models of internet stack helpers. naturally, the trace files should follow a <prefix>-<protocol>-<interface> naming convention.

the trace helpers therefore fall naturally into a two-dimensional taxonomy. there are subtleties that prevent all four classes from behaving identically, but we do strive to make them all work as similarly as possible; and whenever possible there are analogs for all methods in all classes. ✨

  • Device Helper — pcap ✓, ascii ✓
  • Protocol Helper — pcap ✓, ascii ✓

We use an approach called a mixin to add tracing functionality to our helper classes. And bestie, a mixin is literally just a class that hands functionality over to a subclass that inherits it 💅. Inheriting from a mixin is not a form of specialization — it's really just a way to collect functionality together. No cap.

Let's take a quick look at all four of these cases and their respective mixins 👀.


🌸 Pcap Tracing Device Helpers 🌸

The goal of these helpers is to make it easy to add a consistent pcap trace facility to an ns-3 device. We want all of the various flavors of pcap tracing to work the same across all devices, so the methods of these helpers are inherited by device helpers 🫶. Take a look at src/network/helper/trace-helper.h if you want to follow the discussion while looking at real code.

The class PcapHelperForDevice is a mixin that provides the high level functionality for using pcap tracing in an ns-3 device. Every device must implement a single virtual method inherited from this class:

virtual void EnablePcapInternal (std::string prefix, Ptr<NetDevice> nd, bool promiscuous) = 0;

The signature of this method reflects the device-centric view of the situation at this level. All of the public methods inherited from class PcapUserHelperForDevice reduce to calling this single device-dependent implementation method ✨. For example, the lowest level pcap method:

void EnablePcap (std::string prefix, Ptr<NetDevice> nd, bool promiscuous = false, bool explicitFilename = false);

will call the device implementation of EnablePcapInternal directly. All other public pcap tracing methods build on this implementation to provide additional user-level functionality. What this means to you, the user, is that all device helpers in the system will have all of the pcap trace methods available; and these methods will all work in the same way across devices if the device implements EnablePcapInternal correctly. Iconic behavior, fr fr 👑


💖 Pcap Tracing Device Helper Methods 💖

void EnablePcap (std::string prefix, Ptr<NetDevice> nd,
                 bool promiscuous = false, bool explicitFilename = false);
void EnablePcap (std::string prefix, std::string ndName,
                 bool promiscuous = false, bool explicitFilename = false);
void EnablePcap (std::string prefix, NetDeviceContainer d,
                 bool promiscuous = false);
void EnablePcap (std::string prefix, NodeContainer n,
                 bool promiscuous = false);
void EnablePcap (std::string prefix, uint32_t nodeid, uint32_t deviceid,
                 bool promiscuous = false);
void EnablePcapAll (std::string prefix, bool promiscuous = false);

In each of the methods shown above, there's a default parameter called promiscuous that defaults to false. This parameter says the trace should not be gathered in promiscuous mode 🙅. If you do want your traces to include all traffic seen by the device (and if the device supports a promiscuous mode), just add a true parameter to any of the calls above. For example:

Ptr<NetDevice> nd;
...
helper.EnablePcap ("prefix", nd, true);

will enable promiscuous mode captures on the NetDevice specified by nd 🎀.

The first two methods also include a default parameter called explicitFilename that will be discussed below 👇.

You are encouraged to peruse the Doxygen for class PcapHelperForDevice to find the details of these methods; but to summarize …

  • You can enable pcap tracing on a particular node/net-device pair by providing a Ptr<NetDevice> to an EnablePcap method. The Ptr<Node> is implicit since the net device must belong to exactly one Node. For example:
Ptr<NetDevice> nd;
...
helper.EnablePcap ("prefix", nd);
  • You can enable pcap tracing on a particular node/net-device pair by providing a std::string representing an object name service string to an EnablePcap method. The Ptr<NetDevice> is looked up from the name string. Again, the <Node> is implicit since the named net device must belong to exactly one Node. For example:
Names::Add ("server" ...);
Names::Add ("server/eth0" ...);
...
helper.EnablePcap ("prefix", "server/ath0");
  • You can enable pcap tracing on a collection of node/net-device pairs by providing a NetDeviceContainer. For each NetDevice in the container the type is checked. For each device of the proper type (the same type as is managed by the device helper), tracing is enabled 🦋. Again, the <Node> is implicit since the found net device must belong to exactly one Node. For example:
NetDeviceContainer d = ...;
...
helper.EnablePcap ("prefix", d);
  • You can enable pcap tracing on a collection of node/net-device pairs by providing a NodeContainer. For each Node in the NodeContainer its attached NetDevices are iterated. For each NetDevice attached to each node in the container, the type of that device is checked. For each device of the proper type (the same type as is managed by the device helper), tracing is enabled:
NodeContainer n;
...
helper.EnablePcap ("prefix", n);
  • You can enable pcap tracing on the basis of node ID and device ID as well as with explicit Ptr. Each Node in the system has an integer node ID and each device connected to a node has an integer device ID:
helper.EnablePcap ("prefix", 21, 1);
  • Finally, you can enable pcap tracing for all devices in the system, with the same type as that managed by the device helper:
helper.EnablePcapAll ("prefix");

🎀 Pcap Tracing Device Helper Filename Selection 🎀

Implicit in the method descriptions above is the construction of a complete filename by the implementation method. By convention, pcap traces in the ns-3 system are of the form <prefix>-<node id>-<device id>.pcap ✨

As previously mentioned, every node in the system will have a system-assigned node id; and every device will have an interface index (also called a device id) relative to its node. By default, then, a pcap trace file created as a result of enabling tracing on the first device of node 21 using the prefix "prefix" would be prefix-21-1.pcap 🌸.

You can always use the ns-3 object name service to make this more clear. For example, if you use the object name service to assign the name "server" to node 21, the resulting pcap trace file name will automatically become prefix-server-1.pcap, and if you also assign the name "eth0" to the device, your pcap file name will automatically pick this up and be called prefix-server-eth0.pcap 💅.

Finally, two of the methods shown above:

void EnablePcap (std::string prefix, Ptr<NetDevice> nd, bool promiscuous = false, bool explicitFilename = false);
void EnablePcap (std::string prefix, std::string ndName, bool promiscuous = false, bool explicitFilename = false);

have a default parameter called explicitFilename. When set to true, this parameter disables the automatic filename completion mechanism and allows you to create an explicit filename. This option is only available in the methods which enable pcap tracing on a single device ⚠️.

For example, in order to arrange for a device helper to create a single promiscuous pcap capture file of a specific name (my-pcap-file.pcap) on a given device, one could:

Ptr<NetDevice> nd;
...
helper.EnablePcap ("my-pcap-file.pcap", nd, true, true);

The first true parameter enables promiscuous mode traces and the second tells the helper to interpret the prefix parameter as a complete filename 👑.


🌈 Ascii Tracing Device Helpers 🌈

The behavior of the ASCII trace helper mixin is substantially similar to the pcap version. Take a look at src/network/helper/trace-helper.h if you want to follow the discussion while looking at real code.

The class AsciiTraceHelperForDevice adds the high level functionality for using ASCII tracing to a device helper class. As in the pcap case, every device must implement a single virtual method inherited from the ASCII trace mixin:

virtual void EnableAsciiInternal (Ptr<OutputStreamWrapper> stream, std::string prefix, Ptr<NetDevice> nd) = 0;

The signature of this method reflects the device-centric view of the situation at this level; and also the fact that the helper may be writing to a shared output stream 🫶. All of the public ASCII-trace-related methods inherited from class AsciiTraceHelperForDevice reduce to calling this single device-dependent implementation method. For example, the lowest level ASCII trace methods:

void EnableAscii (std::string prefix, Ptr<NetDevice> nd);
void EnableAscii (Ptr<OutputStreamWrapper> stream, Ptr<NetDevice> nd);

will call the device implementation of EnableAsciiInternal directly, providing either a valid prefix or stream ✨. All other public ASCII tracing methods will build on these low-level functions to provide additional user-level functionality. What this means to the user is that all device helpers in the system will have all of the ASCII trace methods available; and these methods will all work in the same way across devices if the devices implement EnablAsciiInternal correctly 😭.


🦋 Ascii Tracing Device Helper Methods 🦋

void EnableAscii (std::string prefix, Ptr<NetDevice> nd);
void EnableAscii (Ptr<OutputStreamWrapper> stream, Ptr<NetDevice> nd);

void EnableAscii (std::string prefix, std::string ndName);
void EnableAscii (Ptr<OutputStreamWrapper> stream, std::string ndName);

void EnableAscii (std::string prefix, NetDeviceContainer d);
void EnableAscii (Ptr<OutputStreamWrapper> stream, NetDeviceContainer d);

void EnableAscii (std::string prefix, NodeContainer n);
void EnableAscii (Ptr<OutputStreamWrapper> stream, NodeContainer n);

void EnableAscii (std::string prefix, uint32_t nodeid, uint32_t deviceid);
void EnableAscii (Ptr<OutputStreamWrapper> stream, uint32_t nodeid, uint32_t deviceid);

void EnableAsciiAll (std::string prefix);
void EnableAsciiAll (Ptr<OutputStreamWrapper> stream);

You are encouraged to peruse the Doxygen for class TraceHelperForDevice to find the details of these methods; but to summarize …

There are twice as many methods available for ASCII tracing as there were for pcap tracing 👀. This is because, in addition to the pcap-style model where traces from each unique node/device pair are written to a unique file, we support a model in which trace information for many node/device pairs is written to a common file 🎀. This means that the <prefix>-<node>-<device> file name generation mechanism is replaced by a mechanism to refer to a common file; and the number of API methods is doubled to allow all combinations.

Just as in pcap tracing, you can enable ASCII tracing on a particular node/net-device pair by providing a Ptr<NetDevice> to an EnableAscii method. The Ptr<Node> is implicit since the net device must belong to exactly one Node. For example:

Trace contexts are written when there's more than one device involved, because otherwise you couldn't tell whose trace is whose. And since you are naming the file yourself here, bestie, you gotta put the .tr in the string — for consistency, okay?

You can turn on ASCII tracing for one specific node/net-device pair by handing an EnablePcap method a std::string that's an object name service string. The Ptr<NetDevice> gets looked up from that name string. And again, the <Node> is implicit because a named net device belongs to exactly one Node, no cap. Like:

Names::Add ("client" ...);
Names::Add ("client/eth0" ...);
Names::Add ("server" ...);
Names::Add ("server/eth0" ...);
...
helper.EnableAscii ("prefix", "client/eth0");
helper.EnableAscii ("prefix", "server/eth0");

That gives you two files, prefix-client-eth0.tr and prefix-server-eth0.tr, each holding the traces for its own device. And because all of the EnableAscii functions are overloaded to take a stream wrapper, you can use that form too:

Names::Add ("client" ...);
Names::Add ("client/eth0" ...);
Names::Add ("server" ...);
Names::Add ("server/eth0" ...);
...
Ptr<OutputStreamWrapper> stream = asciiTraceHelper.CreateFileStream ("trace-file-name.tr");
...
helper.EnableAscii (stream, "client/eth0");
helper.EnableAscii (stream, "server/eth0");

That one gives you a single trace file called trace-file-name.tr holding all the trace events for both devices. The events get told apart by trace context strings. 💅


You can also enable ASCII tracing over a whole collection of node/net-device pairs by passing a NetDeviceContainer. For every NetDevice in the container the type gets checked, and for each device of the proper type — meaning the same type the device helper manages — tracing is enabled. Once again, the <Node> is implicit, since a found net device has to belong to exactly one Node. For example:

NetDeviceContainer d = ...;
...
helper.EnableAscii ("prefix", d);

That spins up a bunch of ASCII trace files, each following the <prefix>-<node id>-<device id>.tr convention. Squishing all the traces into one single file works just like the examples up above:

NetDeviceContainer d = ...;
...
Ptr<OutputStreamWrapper> stream = asciiTraceHelper.CreateFileStream ("trace-file-name.tr");
...
helper.EnableAscii (stream, d);

You can enable ascii tracing on a collection of node/net-device pairs by passing a NodeContainer too. For each Node in the NodeContainer its attached NetDevices get iterated, and for each NetDevice attached to each node in the container, that device's type gets checked. For every device of the proper type — same type the device helper manages — tracing gets enabled:

NodeContainer n;
...
helper.EnableAscii ("prefix", n);

That creates a number of ASCII trace files, each one following the <prefix>-<node id>-<device id>.tr convention. Combining all of the traces into a single file is accomplished similarly to the examples above:

You can enable pcap tracing on the basis of node ID and device ID as well as with explicit Ptr. Every Node in the system has an integer node ID, and every device connected to a node has an integer device ID:

helper.EnableAscii ("prefix", 21, 1);

Of course, the traces can be combined into a single file as shown above. ✨

And finally, you can enable pcap tracing for all devices in the system that match the type the device helper manages:

helper.EnableAsciiAll ("prefix");

This creates a number of ASCII trace files, one for every device in the system of the type managed by the helper. All of these files follow the <prefix>-<node id>-<device id>.tr convention. Combining all the traces into a single file is accomplished similarly to the examples above.

🎀 Ascii Tracing Device Helper Filename Selection 🎀

So here's the tea that's implicit in all those prefix-style method descriptions: the implementation method is the one actually building the complete filenames. By convention, ASCII traces in the ns-3 system look like <prefix>-<node id>-<device id>.tr.

As previously mentioned, every node in the system has a system-assigned node id, and every device has an interface index (also called a device id) relative to its node. So by default, an ASCII trace file created by enabling tracing on the first device of node 21, using the prefix prefix, would be prefix-21-1.tr.

You can always use the ns-3 object name service to make this way clearer, bestie. For example, if you use the object name service to assign the name server to node 21, the resulting ASCII trace file name automatically becomes prefix-server-1.tr, and if you also assign the name eth0 to the device, your ASCII trace file name picks that up automatically too and gets called prefix-server-eth0.tr. Iconic behavior, honestly. 💖

🌈 Pcap Tracing Protocol Helpers 🌈

The whole goal of these mixins is making it easy to bolt a consistent pcap trace facility onto protocols. We want every single flavor of pcap tracing to work the same way across all protocols, so the methods of these helpers get inherited by stack helpers. Go peek at src/network/helper/trace-helper.h if you wanna follow along with the discussion while staring at real code.

In this section we're illustrating the methods as applied to the protocol Ipv4. To specify traces in similar protocols, just swap in the appropriate type. Like, use a Ptr<Ipv6> instead of a Ptr<Ipv4> and call EnablePcapIpv6 instead of EnablePcapIpv4.

The class PcapHelperForIpv4 provides the high level functionality for using pcap tracing in the Ipv4 protocol. Every protocol helper enabling these methods has to implement a single virtual method inherited from this class. There'll be a separate implementation for Ipv6, for example, but the only difference will be in the method names and signatures. Different method names are required to disambiguate class Ipv4 from Ipv6, since both are derived from class Object and the methods share the same signature:

virtual void EnablePcapIpv4Internal (std::string prefix, Ptr<Ipv4> ipv4, uint32_t interface) = 0;

The signature of this method reflects the protocol- and interface-centric view of the situation at this level. All of the public methods inherited from class PcapHelperForIpv4 reduce down to calling this single device-dependent implementation method. For example, the lowest level pcap method:

void EnablePcapIpv4 (std::string prefix, Ptr<Ipv4> ipv4, uint32_t interface);

will call the device implementation of EnablePcapIpv4Internal directly. All other public pcap tracing methods build on this implementation to provide extra user-level functionality. What that means for you as the user is: every protocol helper in the system will have all of the pcap trace methods available, and these methods will all work the same way across protocols — as long as the helper implements EnablePcapIpv4Internal correctly. 🦋

💜 Pcap Tracing Protocol Helper Methods 💜

These methods are designed to be in one-to-one correspondence with the Node- and NetDevice-centric versions of the device versions. Instead of Node and NetDevice pair constraints, we use protocol and interface constraints.

Note that just like in the device version, there are six methods:

void EnablePcapIpv4 (std::string prefix, Ptr<Ipv4> ipv4, uint32_t interface);
void EnablePcapIpv4 (std::string prefix, std::string ipv4Name, uint32_t interface);
void EnablePcapIpv4 (std::string prefix, Ipv4InterfaceContainer c);
void EnablePcapIpv4 (std::string prefix, NodeContainer n);
void EnablePcapIpv4 (std::string prefix, uint32_t nodeid, uint32_t interface);
void EnablePcapIpv4All (std::string prefix);

You're encouraged to peruse the Doxygen for class PcapHelperForIpv4 to find the details of these methods; but to summarize …

You can enable pcap tracing on a particular protocol/interface pair by providing a Ptr<Ipv4> and interface to an EnablePcap method. For example:

Ptr<Ipv4> ipv4 = node->GetObject<Ipv4> ();
...
helper.EnablePcapIpv4 ("prefix", ipv4, 0);

You can enable pcap tracing on a particular node/net-device pair by providing a std::string representing an object name service string to an EnablePcap method. The Ptr<Ipv4> gets looked up from the name string. For example:

Names::Add ("serverIPv4" ...);
...
helper.EnablePcapIpv4 ("prefix", "serverIpv4", 1);

You can enable pcap tracing on a collection of protocol/interface pairs by providing an Ipv4InterfaceContainer. For each Ipv4 / interface pair in the container the protocol type gets checked. For each protocol of the proper type — the same type the device helper manages — tracing gets enabled for the corresponding interface. For example:

NodeContainer nodes;
...
NetDeviceContainer devices = deviceHelper.Install (nodes);
...
Ipv4AddressHelper ipv4;
ipv4.SetBase ("10.1.1.0", "255.255.255.0");
Ipv4InterfaceContainer interfaces = ipv4.Assign (devices);
...
helper.EnablePcapIpv4 ("prefix", interfaces);

You can enable pcap tracing on a collection of protocol/interface pairs by providing a NodeContainer. For each Node in the NodeContainer the appropriate protocol gets found. For each protocol, its interfaces get enumerated and tracing is enabled on the resulting pairs. For example:

NodeContainer n;
...
helper.EnablePcapIpv4 ("prefix", n);

You can enable pcap tracing on the basis of node ID and interface as well. In this case, the node-id gets translated to a Ptr<Node> and the appropriate protocol is looked up in the node. The resulting protocol and interface are what specify the resulting trace source:

helper.EnablePcapIpv4 ("prefix", 21, 1);

And last but never ever least, bestie — you can flip on pcap tracing for every single interface in the whole entire system 💅, with the associated protocol being the same type as the one your device helper manages:

helper.EnablePcapIpv4All ("prefix");

🎀 Pcap Tracing Protocol Helper Filename Selection 🎀

Okay so here's the tea ☕ — hidden inside every method description up there is the fact that the implementation is the one building the complete filenames. By convention, pcap traces taken for devices in the ns-3 system look like <prefix>-<node id>-<device id>.pcap. Iconic naming scheme, no notes.

But for protocol traces? It's a little different, and here's why 🧐: there's a one-to-one correspondence between protocols and Nodes. That's because protocol Objects are aggregated to Node Objects. And since there's no global protocol id in the system, we use the corresponding node id when naming files. Which means — and listen closely — there's a possibility for file name collisions in automatically chosen trace file names 😭. For this exact reason, the file name convention is changed for protocol traces. Not a vibe, just a fact.

As mentioned before, every node in the system has a system-assigned node id. Since there's a one-to-one correspondence between protocol instances and node instances, we use the node id. Each interface has an interface id relative to its protocol. So the convention is:

"<prefix>-n<node id>-i<interface id>.pcap"

...for trace file naming in protocol helpers. Periodt. 💅

So by default, a pcap trace file created as a result of enabling tracing on interface 1 of the Ipv4 protocol of node 21 using the prefix "prefix" would be:

prefix-n21-i1.pcap

You can always use the ns-3 object name service to make this more clear ✨. For example, if you use the object name service to assign the name "serverIpv4" to the Ptr<Ipv4> on node 21, the resulting pcap trace file name will automatically become:

prefix-nserverIpv4-i1.pcap


🦋 Ascii Tracing Protocol Helpers 🦋

The behavior of the ASCII trace helpers is substantially similar to the pcap case — like, same energy, different outfit 💖. Go peek at src/network/helper/trace-helper.h if you want to follow the discussion while looking at real code. Do it, it's giving study session.

In this section we'll be illustrating the methods as applied to the protocol Ipv4. To specify traces in similar protocols, just substitute the appropriate type 🔁. For example, use a Ptr<Ipv6> instead of a Ptr<Ipv4> and call EnableAsciiIpv6 instead of EnableAsciiIpv4.

The class AsciiTraceHelperForIpv4 adds the high level functionality for using ASCII tracing to a protocol helper. Each protocol that enables these methods must implement a single virtual method inherited from this class:

virtual void EnableAsciiIpv4Internal (Ptr<OutputStreamWrapper> stream, std::string prefix,
                                      Ptr<Ipv4> ipv4, uint32_t interface) = 0;

The signature of this method reflects the protocol- and interface-centric view of the situation at this level, and also the fact that the helper may be writing to a shared output stream. 🌊

All of the public methods inherited from class PcapAndAsciiTraceHelperForIpv4 reduce to calling this single device-dependent implementation method. For example, the lowest level ascii trace methods:

void EnableAsciiIpv4 (std::string prefix, Ptr<Ipv4> ipv4, uint32_t interface);
void EnableAsciiIpv4 (Ptr<OutputStreamWrapper> stream, Ptr<Ipv4> ipv4, uint32_t interface);

...will call the device implementation of EnableAsciiIpv4Internal directly, providing either the prefix or the stream. All other public ascii tracing methods will build on these low-level functions to provide additional user-level functionality.

What this means for you, the user 🫶: all device helpers in the system will have all of the ascii trace methods available, and these methods will all work in the same way across protocols — if the protocols implement EnablAsciiIpv4Internal correctly. (Yes, that's how it's spelled up in here. Copy it exactly. Don't @ me. 💀)


🌸 Ascii Tracing Device Helper Methods 🌸

Behold, the full menu 👑:

void EnableAsciiIpv4 (std::string prefix, Ptr<Ipv4> ipv4, uint32_t interface);
void EnableAsciiIpv4 (Ptr<OutputStreamWrapper> stream, Ptr<Ipv4> ipv4, uint32_t interface);

void EnableAsciiIpv4 (std::string prefix, std::string ipv4Name, uint32_t interface);
void EnableAsciiIpv4 (Ptr<OutputStreamWrapper> stream, std::string ipv4Name, uint32_t interface);

void EnableAsciiIpv4 (std::string prefix, Ipv4InterfaceContainer c);
void EnableAsciiIpv4 (Ptr<OutputStreamWrapper> stream, Ipv4InterfaceContainer c);

void EnableAsciiIpv4 (std::string prefix, NodeContainer n);
void EnableAsciiIpv4 (Ptr<OutputStreamWrapper> stream, NodeContainer n);

void EnableAsciiIpv4 (std::string prefix, uint32_t nodeid, uint32_t deviceid);
void EnableAsciiIpv4 (Ptr<OutputStreamWrapper> stream, uint32_t nodeid, uint32_t interface);

void EnableAsciiIpv4All (std::string prefix);
void EnableAsciiIpv4All (Ptr<OutputStreamWrapper> stream);

You're encouraged to peruse the Doxygen for class PcapAndAsciiHelperForIpv4 to find the details of these methods, but to summarize… 📝

There are twice as many methods available for ASCII tracing as there were for pcap tracing. Why? Because on top of the pcap-style model — where traces from each unique protocol/interface pair get written to a unique file — we also support a model where trace information for many protocol/interface pairs gets written to a common file. This means the <prefix>-n<node id>-<interface> file name generation mechanism is replaced by a mechanism to refer to a common file, and the number of API methods is doubled to allow all combinations. Slay, honestly. 💅

Just like in pcap tracing, you can enable ASCII tracing on a particular protocol/interface pair by providing a Ptr<Ipv4> and an interface to an EnableAscii method. For example:

Ptr<Ipv4> ipv4;
...
helper.EnableAsciiIpv4 ("prefix", ipv4, 1);

In this case, no trace contexts are written to the ASCII trace file since they would be redundant. The system will pick the file name to be created using the same rules as described in the pcap section — except the file will have the suffix .tr instead of .pcap. Little outfit change, still cute. 🎀

If you want to enable ASCII tracing on more than one interface and have all traces sent to a single file, you can do that too, by using an object to refer to a single file. We already have something similar to this in the "cwnd" example above:

Ptr<Ipv4> protocol1 = node1->GetObject<Ipv4> ();
Ptr<Ipv4> protocol2 = node2->GetObject<Ipv4> ();
...
Ptr<OutputStreamWrapper> stream = asciiTraceHelper.CreateFileStream ("trace-file-name.tr");
...
helper.EnableAsciiIpv4 (stream, protocol1, 1);
helper.EnableAsciiIpv4 (stream, protocol2, 1);

In this case, trace contexts are written to the ASCII trace file, since they're required to disambiguate traces from the two interfaces. Note that since the user is completely specifying the file name, the string should include the .tr for consistency. 👀

You can enable ASCII tracing on a particular protocol by providing a std::string representing an object name service string to an EnablePcap method. The Ptr<Ipv4> is looked up from the name string. The <Node> in the resulting filenames is implicit since there is a one-to-one correspondence between protocol instances and nodes. For example:

Names::Add ("node1Ipv4" ...);
Names::Add ("node2Ipv4" ...);
...
helper.EnableAsciiIpv4 ("prefix", "node1Ipv4", 1);
helper.EnableAsciiIpv4 ("prefix", "node2Ipv4", 1);

This would result in two files named prefix-nnode1Ipv4-i1.tr and prefix-nnode2Ipv4-i1.tr, with traces for each interface in the respective trace file. And since all of the EnableAscii functions are overloaded to take a stream wrapper, you can use that form as well:

Names::Add ("node1Ipv4" ...);
Names::Add ("node2Ipv4" ...);
...
Ptr<OutputStreamWrapper> stream = asciiTraceHelper.CreateFileStream ("trace-file-name.tr");
...
helper.EnableAsciiIpv4 (stream, "node1Ipv4", 1);
helper.EnableAsciiIpv4 (stream, "node2Ipv4", 1);

This would result in a single trace file called trace-file-name.tr that contains all of the trace events for both interfaces. The events would be disambiguated by trace context strings. Obsessed with the consolidation tbh. 💜

You can enable ASCII tracing on a collection of protocol/interface pairs by providing an Ipv4InterfaceContainer. For each protocol of the proper type (the same type as is managed by the device helper), tracing is enabled for the corresponding interface. Again, the <Node> is implicit since there's a one-to-one correspondence between each protocol and its node. For example:

NodeContainer nodes;
...
NetDeviceContainer devices = deviceHelper.Install (nodes);
...
Ipv4AddressHelper ipv4;
ipv4.SetBase ("10.1.1.0", "255.255.255.0");
Ipv4InterfaceContainer interfaces = ipv4.Assign (devices);
...
...
helper.EnableAsciiIpv4 ("prefix", interfaces);

This would result in a number of ASCII trace files being created, each of which follows the <prefix>-n<node id>-i<interface>.tr convention. Combining all of the traces into a single file is accomplished similarly to the examples above:

NodeContainer nodes;
...
NetDeviceContainer devices = deviceHelper.Install (nodes);
...
Ipv4AddressHelper ipv4;
ipv4.SetBase ("10.1.1.0", "255.255.255.0");
Ipv4InterfaceContainer interfaces = ipv4.Assign (devices);
...
Ptr<OutputStreamWrapper> stream = asciiTraceHelper.CreateFileStream ("trace-file-name.tr");
...
helper.EnableAsciiIpv4 (stream, interfaces);

You can enable ASCII tracing on a collection of protocol/interface pairs by providing a NodeContainer. For each Node in the NodeContainer the appropriate protocol is found. For each protocol, its interfaces are enumerated and tracing is enabled on the resulting pairs. For example:

NodeContainer n;
...
helper.EnableAsciiIpv4 ("prefix", n);

This would result in a number of ASCII trace files being created, each of which follows the <prefix>-<node id>-<device id>.tr convention. Combining all of the traces into a single file is accomplished similarly to the examples above:

You can enable pcap tracing on the basis of node ID and device ID as well. In this case, the node-id is translated to a Ptr<Node> and the appropriate protocol is looked up in the node. The resulting protocol and interface are used to specify the resulting trace source:

helper.EnableAsciiIpv4 ("prefix", 21, 1);

Of course, the traces can be combined into a single file as shown above. 💫

And finally — the grand finale ✨ — you can enable ASCII tracing for all interfaces in the system, with the associated protocol being the same type as that managed by the device helper:

helper.EnableAsciiIpv4All ("prefix");

okay so this next bit is literally just the receipts on how those files get named, bestie — no new magic, just the naming convention doing its thing 💅

This would result in a number of ASCII trace files being created, one for every interface in the system related to a protocol of the type managed by the helper. All of these files will follow the <prefix>-n<node id>-i<interface.tr convention. Combining all of the traces into a single file is accomplished similarly to the examples above.

🌸 Ascii Tracing Device Helper Filename Selection 🌸

Implicit in the prefix-style method descriptions above is the construction of the complete filenames by the implementation method. By convention, ASCII traces in the ns-3 system are of the form <prefix>-<node id>-<device id>.tr.

As previously mentioned, every node in the system will have a system-assigned node id. Since there is a one-to-one correspondence between protocols and nodes we use to node-id to identify the protocol identity. Every interface on a given protocol will have an interface index (also called simply an interface) relative to its protocol. By default, then, an ASCII trace file created as a result of enabling tracing on the first device of node 21, using the prefix prefix, would be prefix-n21-i1.tr. Use the prefix to disambiguate multiple protocols per node.

You can always use the ns-3 object name service to make this more clear. For example, if you use the object name service to assign the name serverIpv4 to the protocol on node 21, and also specify interface one, the resulting ASCII trace file name will automatically become, prefix-nserverIpv4-1.tr.

🔍 Tracing implementation details 🔍


✨ DATA COLLECTION ✨

Okay so this chapter is all about the ns-3 Data Collection Framework (aka DCF), which gives you the ability to grab data generated by models in the simulator, do on-line reduction and data processing on it, and then marshal raw or transformed data into whatever output formats you vibe with. 💖

The framework presently supports standalone ns-3 runs that don't rely on any external program execution control. The objects provided by the DCF may be hooked to ns-3 trace sources to enable data processing.

The source code for the classes lives in the directory src/stats. 📂

This chapter is organized as follows. First, an overview of the architecture is presented. Next, the helpers for these classes are presented; this initial treatment should allow basic use of the data collection framework for many use cases. Users who wish to produce output outside of the scope of the current helpers, or who wish to create their own data collection objects, should read the remainder of the chapter, which goes into detail about all of the basic DCF object types and provides low-level coding examples.

🫶 Design 🫶

The DCF consists of three basic classes:

  • Probe is a mechanism to instrument and control the output of simulation data that is used to monitor interesting events. It produces output in the form of one or more ns-3 trace sources. Probe objects are hooked up to one or more trace sinks (called Collectors), which process samples on-line and prepare them for output.
  • Collector consumes the data generated by one or more Probe objects. It performs transformations on the data, such as normalization, reduction, and the computation of basic statistics. Collector objects do not produce data that is directly output by the ns-3 run; instead, they output data downstream to another type of object, called Aggregator, which performs that function. Typically, Collectors output their data in the form of trace sources as well, allowing collectors to be chained in series.
  • Aggregator is the end point of the data collected by a network of Probes and Collectors. The main responsibility of the Aggregator is to marshal data and their corresponding metadata, into different output formats such as plain text files, spreadsheet files, or databases.

All three of these classes provide the capability to dynamically turn themselves on or off throughout a simulation.

Any standalone ns-3 simulation run that uses the DCF will typically create at least one instance of each of the three classes above.

[image] Data Collection Framework overview.

The overall flow of data processing is depicted in Data Collection Framework overview. On the left side, a running ns-3 simulation is depicted. In the course of running the simulation, data is made available by models through trace sources, or via other means. The diagram depicts that probes can be connected to these trace sources to receive data asynchronously, or probes can poll for data. Data is then passed to a collector object that transforms the data. Finally, an aggregator can be connected to the outputs of the collector, to generate plots, files, or databases.

[image] Data Collection Framework aggregation.

A variation on the above figure is provided in Data Collection Framework aggregation. This second figure illustrates that the DCF objects may be chained together in a manner that downstream objects take inputs from multiple upstream objects. The figure conceptually shows that multiple probes may generate output that is fed into a single collector; as an example, a collector that outputs a ratio of two counters would typically acquire each counter data from separate probes. Multiple collectors can also feed into a single aggregator, which (as its name implies) may collect a number of data streams for inclusion into a single plot, file, or database.

🧚 Data Collection Helpers 🧚

The full flexibility of the data collection framework is provided by the interconnection of probes, collectors, and aggregators. Performing all of these interconnections leads to many configuration statements in user programs. For ease of use, some of the most common operations can be combined and encapsulated in helper functions. In addition, some statements involving ns-3 trace sources do not have Python bindings, due to limitations in the bindings.

🌈 Data Collection Helpers Overview 🌈

In this section, we provide an overview of some helper classes that have been created to ease the configuration of the data collection framework for some common use cases. The helpers allow users to form common operations with only a few statements in their C++ or Python programs. But, this ease of use comes at the cost of significantly less flexibility than low-level configuration can provide, and the need to explicitly code support for new Probe types into the helpers (to work around an issue described below).

The emphasis on the current helpers is to marshal data out of ns-3 trace sources into gnuplot plots or text files, without a high degree of output customization or statistical processing (initially). Also, the use is constrained to the available probe types in ns-3. Later sections of this documentation will go into more detail about creating new Probe types, as well as details about hooking together Probes, Collectors, and Aggregators in custom arrangements.

To date, two Data Collection helpers have been implemented:

  • GnuplotHelper
  • FileHelper

💅 GnuplotHelper 💅

The GnuplotHelper is a helper class for producing output files used to make gnuplots. The overall goal is to provide the ability for users to quickly make plots from data exported in ns-3 trace sources. By default, a minimal amount of data transformation is performed; the objective is to generate plots with as few (default) configuration statements as possible.

⭐ GnuplotHelper Overview ⭐

The GnuplotHelper will create 3 different files at the end of the simulation:

  • A space separated gnuplot data file
  • A gnuplot control file
  • A shell script to generate the gnuplot

There are two configuration statements that are needed to produce plots. The first statement configures the plot (filename, title, legends, and output type, where the output type defaults to PNG if unspecified):

void ConfigurePlot (const std::string &outputFileNameWithoutExtension,
                    const std::string &title,
                    const std::string &xLegend,
                    const std::string &yLegend,
                    const std::string &terminalType = ".png");

The second statement hooks the trace source of interest:

void PlotProbe (const std::string &typeId,
                const std::string &path,
                const std::string &probeTraceSource,
                const std::string &title);

The arguments are as follows:

  • typeId: The ns-3 TypeId of the Probe
  • path: The path in the ns-3 configuration namespace to one or more trace sources
  • probeTraceSource: Which output of the probe (itself a trace source) should be plotted
  • title: The title to associate with the dataset(s) (in the gnuplot legend)

A variant on the PlotProbe above is to specify a fifth optional argument that controls where in the plot the key (legend) is placed.

A fully worked example (from seventh.cc) is shown below:

// Create the gnuplot helper.
GnuplotHelper plotHelper;

// Configure the plot.
// Configure the plot.  The first argument is the file name prefix
// for the output files generated.  The second, third, and fourth
// arguments are, respectively, the plot title, x-axis, and y-axis labels
plotHelper.ConfigurePlot ("seventh-packet-byte-count",
                          "Packet Byte Count vs. Time",
                          "Time (Seconds)",
                          "Packet Byte Count",
                          "png");

// Specify the probe type, trace source path (in configuration namespace), and
// probe output trace source ("OutputBytes") to plot.  The fourth argument
// specifies the name of the data series label on the plot.  The last
// argument formats the plot by specifying where the key should be placed.
plotHelper.PlotProbe (probeType,
                      tracePath,
                      "OutputBytes",
                      "Packet Byte Count",
                      GnuplotAggregator::KEY_BELOW);

In this example, the probeType and tracePath are as follows (for IPv4):

probeType = "ns3::Ipv4PacketProbe";
tracePath = "/NodeList/*/$ns3::Ipv4L3Protocol/Tx";

The probeType is a key parameter for this helper to work. This TypeId must be registered in the system, and the signature on the Probe's trace sink must match that of the trace source it is being hooked to. Probe types are pre-defined for a number of data types corresponding to ns-3 traced values, and for a few other trace source signatures such as the 'Tx' trace source of ns3::Ipv4L3Protocol class.

Note that the trace source path you hand over might have wildcards in it. When that happens, multiple datasets get plotted on the one plot — one for each matched path. It's giving "one plot, many vibes" 💅✨

The main output it produces is three files:

seventh-packet-byte-count.dat
seventh-packet-byte-count.plt
seventh-packet-byte-count.sh

At this point bestie, you've got choices: you can hand-edit the .plt file for further customizations, or you can just run it through gnuplot. Running sh seventh-packet-byte-count.sh simply runs the plot through gnuplot, like so:

[image] 2-D Gnuplot Created by seventh.cc Example.

And you can see all the key elements sitting pretty on the plot — legend, title, legend placement, xlabel, ylabel, and path for the data. Since there were two matches to the configuration path we gave it, two data series show up:

  • Packet Byte Count-0 corresponds to /NodeList/0/$ns3::Ipv4L3Protocol/Tx
  • Packet Byte Count-1 corresponds to /NodeList/1/$ns3::Ipv4L3Protocol/Tx

GnuplotHelper ConfigurePlot 🎀

The GnuplotHelper's ConfigurePlot() function configures plots. Iconic behavior, no notes.

It's got this prototype:

void ConfigurePlot (const std::string &outputFileNameWithoutExtension,
                    const std::string &title,
                    const std::string &xLegend,
                    const std::string &yLegend,
                    const std::string &terminalType = ".png");

And these are the arguments it takes:

  • outputFileNameWithoutExtension — name of gnuplot related files to write with no extension.
  • title — plot title string to use for this plot.
  • xLegend — the legend for the x horizontal axis.
  • yLegend — the legend for the y vertical axis.
  • terminalType — terminal type setting string for output. The default terminal type is "png".

The GnuplotHelper's ConfigurePlot() function configures plot related parameters for this gnuplot helper so it will create a space separated gnuplot data file named outputFileNameWithoutExtension + ".dat", a gnuplot control file named outputFileNameWithoutExtension + ".plt", and a shell script to generate the gnuplot named outputFileNameWithoutExtension + ".sh". Three files, one call, we love a multitasker 💖

An example of how to use this function can be seen in the seventh.cc code described above, where it was used like this:

plotHelper.ConfigurePlot ("seventh-packet-byte-count",
                          "Packet Byte Count vs. Time",
                          "Time (Seconds)",
                          "Packet Byte Count",
                          "png");

GnuplotHelper PlotProbe 🦋

The GnuplotHelper's PlotProbe() function can be used to plot values generated by probes. Lowkey essential.

It has this prototype:

void PlotProbe (const std::string &typeId,
                const std::string &path,
                const std::string &probeTraceSource,
                const std::string &title,
                enum GnuplotAggregator::KeyLocation keyLocation = GnuplotAggregator::KEY_INSIDE);

And the arguments:

  • typeId — the type ID for the probe created by this helper.
  • path — config path to access the trace source.
  • probeTraceSource — the probe trace source to access.
  • title — the title to be associated to this dataset.
  • keyLocation — the location of the key in the plot. The default location is inside.

The GnuplotHelper's PlotProbe() function plots a dataset generated by hooking the ns-3 trace source with a probe created by the helper, and then plotting the values from the probeTraceSource. The dataset will have the provided title, and will consist of the 'newValue' at each timestamp. Simple as that, fr fr.

If the config path has more than one match in the system because there's a wildcard, then one dataset for each match will be plotted. The dataset titles will be suffixed with the matched characters for each of the wildcards in the config path, separated by spaces. For example — and write this down bestie — if the proposed dataset title is the string "bytes", and there are two wildcards in the path, then dataset titles like "bytes-0 0" or "bytes-12 9" will be possible as labels for the datasets that are plotted. Cute, right? 🎀

An example of how to use this function can be seen in the seventh.cc code described above, where it was used (with variable substitution) like this:

plotHelper.PlotProbe ("ns3::Ipv4PacketProbe",
                      "/NodeList/*/$ns3::Ipv4L3Protocol/Tx",
                      "OutputBytes",
                      "Packet Byte Count",
                      GnuplotAggregator::KEY_BELOW);

Other Examples 🌈

Gnuplot Helper Example

A slightly simpler example than the seventh.cc example lives in src/stats/examples/gnuplot-helper-example.cc. The following 2-D gnuplot was created using the example.

[image] 2-D Gnuplot Created by gnuplot-helper-example.cc Example.

In this example, there's an Emitter object that increments its counter according to a Poisson process and then emits the counter's value as a trace source. Look at her go ✨

Ptr<Emitter> emitter = CreateObject<Emitter> ();
Names::Add ("/Names/Emitter", emitter);

Note that because there are no wildcards in the path used below, only 1 datastream was drawn in the plot. This single datastream in the plot is simply labeled "Emitter Count", with no extra suffixes like one would see if there were wildcards in the path. No chaos, just calm 💜

// Create the gnuplot helper.
GnuplotHelper plotHelper;

// Configure the plot.
plotHelper.ConfigurePlot ("gnuplot-helper-example",
                          "Emitter Counts vs. Time",
                          "Time (Seconds)",
                          "Emitter Count",
                          "png");

// Plot the values generated by the probe.  The path that we provide
// helps to disambiguate the source of the trace.
plotHelper.PlotProbe ("ns3::Uinteger32Probe",
                      "/Names/Emitter/Counter",
                      "Output",
                      "Emitter Count",
                      GnuplotAggregator::KEY_INSIDE);

FileHelper 🍓

The FileHelper is a helper class used to put data values into a file. The overall goal is to give users the ability to quickly make formatted text files from data exported in ns-3 trace sources. By default, a minimal amount of data transformation is performed; the objective is to generate files with as few (default) configuration statements as possible. Efficiency queen 👑

FileHelper Overview

The FileHelper will create 1 or more text files at the end of the simulation.

The FileHelper can create 4 different types of text files:

  • Formatted
  • Space separated (the default)
  • Comma separated
  • Tab separated

Formatted files use C-style format strings and the sprintf() function to print their values in the file being written.

The following text file with 2 columns of formatted values named seventh-packet-byte-count-0.txt was created using more new code that was added to the original ns-3 Tutorial example's code. Only the first 10 lines of this file are shown here for brevity.

Time (Seconds) = 1.000e+00    Packet Byte Count = 40
Time (Seconds) = 1.004e+00    Packet Byte Count = 40
Time (Seconds) = 1.004e+00    Packet Byte Count = 576
Time (Seconds) = 1.009e+00    Packet Byte Count = 576
Time (Seconds) = 1.009e+00    Packet Byte Count = 576
Time (Seconds) = 1.015e+00    Packet Byte Count = 512
Time (Seconds) = 1.017e+00    Packet Byte Count = 576
Time (Seconds) = 1.017e+00    Packet Byte Count = 544
Time (Seconds) = 1.025e+00    Packet Byte Count = 576
Time (Seconds) = 1.025e+00    Packet Byte Count = 544

...

okay so there's also this other text file, two columns of formatted values, and it's named seventh-packet-byte-count-1.txt 🎀. It got made by the exact same new code we added to the original ns-3 Tutorial example's code. And bc we love brevity, here's only the first 10 lines 💅:

Time (Seconds) = 1.002e+00    Packet Byte Count = 40
Time (Seconds) = 1.007e+00    Packet Byte Count = 40
Time (Seconds) = 1.013e+00    Packet Byte Count = 40
Time (Seconds) = 1.020e+00    Packet Byte Count = 40
Time (Seconds) = 1.028e+00    Packet Byte Count = 40
Time (Seconds) = 1.036e+00    Packet Byte Count = 40
Time (Seconds) = 1.045e+00    Packet Byte Count = 40
Time (Seconds) = 1.053e+00    Packet Byte Count = 40
Time (Seconds) = 1.061e+00    Packet Byte Count = 40
Time (Seconds) = 1.069e+00    Packet Byte Count = 40

...

The new code that produced both those text files is right here ⬇️. And bestie, more deets about this API are coming in a later section, so don't stress 🌸.

lowkey important: because there were 2 matches for the wildcard in the path, 2 separate text files got created ✨. The first one, named seventh-packet-byte-count-0.txt, matches the wildcard where the * got replaced with 0. The second one, named seventh-packet-byte-count-1.txt, matches where the * got replaced with 1 🦋. And also — not me forgetting this — the function call to WriteProbe() will throw an error message if there are no matches for a path that contains wildcards. So like, no matches = no vibes, just an error 💀.

// Create the file helper.
FileHelper fileHelper;

// Configure the file to be written.
fileHelper.ConfigureFile ("seventh-packet-byte-count",
                          FileAggregator::FORMATTED);

// Set the labels for this formatted output file.
fileHelper.Set2dFormat ("Time (Seconds) = %.3e\tPacket Byte Count = %.0f");

// Write the values generated by the probe.
fileHelper.WriteProbe ("ns3::Ipv4PacketProbe",
                       "/NodeList/*/$ns3::Ipv4L3Protocol/Tx",
                       "OutputBytes");

FileHelper ConfigureFile 📝

The FileHelper's ConfigureFile() function is your girl for configuring text files, periodt ✨.

It has this prototype:

void ConfigureFile (const std::string &outputFileNameWithoutExtension,
                    enum FileAggregator::FileType fileType = FileAggregator::SPACE_SEPARATED);

And these are its arguments 💖:

  • outputFileNameWithoutExtension — Name of output file to write with no extension.
  • fileType — Type of file to write. The default type of file is space separated.

So here's the tea: the FileHelper's ConfigureFile() function configures text file related parameters for the file helper so it'll create a file named outputFileNameWithoutExtension plus possible extra information from wildcard matches plus ".txt", with values printed as specified by fileType 🎀. And the default file type is space-separated, iykyk.

An example of how to use this function is literally right there in the seventh.cc code above, where it was used like this:

fileHelper.ConfigureFile ("seventh-packet-byte-count",
                          FileAggregator::FORMATTED);

FileHelper WriteProbe 🎯

The FileHelper's WriteProbe() function writes values generated by probes to text files, slay 💅.

It has this prototype:

void WriteProbe (const std::string &typeId,
                 const std::string &path,
                 const std::string &probeTraceSource);

And these are its arguments 🌸:

  • typeId — The type ID for the probe to be created.
  • path — Config path to access the trace source.
  • probeTraceSource — The probe trace source to access.

The FileHelper's WriteProbe() function creates output text files generated by hooking the ns-3 trace source with a probe created by the helper, and then writing the values from the probeTraceSource 🦋. The output file names will have the text stored in the member variable m_outputFileNameWithoutExtension plus ".txt", and will consist of the 'newValue' at each timestamp ⭐.

If the config path has more than one match in the system because there's a wildcard, then one output file for each match will be created 😌. The output file names will contain the text in m_outputFileNameWithoutExtension plus the matched characters for each of the wildcards in the config path, separated by dashes, plus ".txt" ✨. For example, if the value in m_outputFileNameWithoutExtension is the string "packet-byte-count", and there are two wildcards in the path, then output file names like "packet-byte-count-0-0.txt" or "packet-byte-count-12-9.txt" will be possible as names for the files that will be created 👑.

An example of how to use this function can be seen in the seventh.cc code described above where it was used as follows:

fileHelper.WriteProbe ("ns3::Ipv4PacketProbe",
                       "/NodeList/*/$ns3::Ipv4L3Protocol/Tx",
                       "OutputBytes");

Other Examples 🌈

File Helper Example 🍓

A slightly simpler example than the seventh.cc one lives in src/stats/examples/file-helper-example.cc 💜. This example only uses the FileHelper, no cap.

The following text file with 2 columns of formatted values, named file-helper-example.txt, was created using the example 🎀. Only the first 10 lines of this file are shown here for brevity:

Time (Seconds) = 0.203  Count = 1
Time (Seconds) = 0.702  Count = 2
Time (Seconds) = 1.404  Count = 3
Time (Seconds) = 2.368  Count = 4
Time (Seconds) = 3.364  Count = 5
Time (Seconds) = 3.579  Count = 6
Time (Seconds) = 5.873  Count = 7
Time (Seconds) = 6.410  Count = 8
Time (Seconds) = 6.472  Count = 9
...

In this example, there's an Emitter object that increments its counter according to a Poisson process and then emits the counter's value as a trace source ✨:

Ptr<Emitter> emitter = CreateObject<Emitter> ();
Names::Add ("/Names/Emitter", emitter);

And note: because there are no wildcards in the path used below, only 1 text file was created 🦋. This single text file is simply named "file-helper-example.txt", with no extra suffixes like you'd see if there were wildcards in the path. The way that matters 💅:

// Create the file helper.
FileHelper fileHelper;

// Configure the file to be written.
fileHelper.ConfigureFile ("file-helper-example",
                          FileAggregator::FORMATTED);

// Set the labels for this formatted output file.
fileHelper.Set2dFormat ("Time (Seconds) = %.3e\tCount = %.0f");

// Write the values generated by the probe.  The path that we
// provide helps to disambiguate the source of the trace.
fileHelper.WriteProbe ("ns3::Uinteger32Probe",
                       "/Names/Emitter/Counter",
                       "Output");

Scope and Limitations 🫶

Currently, only these Probes have been implemented and connected to the GnuplotHelper and to the FileHelper 😌:

  • BooleanProbe
  • DoubleProbe
  • Uinteger8Probe
  • Uinteger16Probe
  • Uinteger32Probe
  • TimeProbe
  • PacketProbe
  • ApplicationPacketProbe
  • Ipv4PacketProbe

These Probes, therefore, are the only TypeIds available to be used in PlotProbe() and WriteProbe() ⭐. So like, don't come for them asking for others, they're not there yet 💀.

In the next few sections, we cover each of the fundamental object types (Probe, Collector, and Aggregator) in more detail, and show how they can be connected together using lower-level API 👑.


Probes 🔥

This section details the functionalities provided by the Probe class to an ns-3 simulation, and gives examples on how to code them in a program ✨. This section is meant for users interested in developing simulations with the ns-3 tools and using the Data Collection Framework — of which the Probe class is a part — to generate data output with their simulation's results 🎀💖.

✨ Probe Overview ✨

okay so bestie, a Probe object is literally meant to be connected to some variable in your simulation — one whose values throughout the whole experiment actually matter to you. The Probe records what values that variable was serving throughout the simulation and then passes that data along to another member of the Data Collection Framework. And yeah, what happens after the Probe spits out its output is out of scope for this section, but like — by the end of the simulation you're gonna have detailed info about what values were stored inside the variable you were probing the entire time. 💅

Typically a Probe gets connected to an ns-3 trace source. That way, whenever the trace source exports a new value, the Probe consumes it (and then exports it downstream to another object through its own trace source). It's giving relay race energy fr fr. 🏃‍♀️

You can think of the Probe as kind of a filter on trace sources. The main reasons you might hook to a Probe instead of just hooking directly to a trace source are:

  • Probes can be dynamically turned on and off during the simulation with calls to Enable() and Disable(). For example, you might turn off the outputting of data during the simulation warmup phase.
  • Probes may perform operations on the data to extract values from more complicated structures — for instance, outputting the packet size value from a received ns3::Packet.
  • Probes register a name in the ns3::Config namespace (using Names::Add ()) so that other objects can refer to them.
  • Probes provide a static method that lets you manipulate a Probe by name, like what's done in ns2measure [Cic06]:
Stat::put ("my_metric", ID, sample);

The ns-3 equivalent of that ns2measure code is, e.g.:

DoubleProbe::SetValueByPath ("/path/to/probe", sample);

🌸 Creation 🌸

Heads up bestie: a Probe base class object can not be created because it's an abstract base class — like, it has pure virtual functions that haven't been implemented. So instead, an object of type DoubleProbe, which is a subclass of the Probe class, gets created here to show you what needs to be done.

You declare a DoubleProbe in dynamic memory by using the smart pointer class (Ptr<T>). To create a DoubleProbe in dynamic memory with smart pointers, you just call the ns-3 method CreateObject():

Ptr<DoubleProbe> myprobe = CreateObject<DoubleProbe> ();

The declaration above creates DoubleProbes using the default values for its attributes. There are four attributes in the DoubleProbe class — two in the base class object DataCollectionObject, and two in the Probe base class:

  • "Name" (DataCollectionObject), a StringValue
  • "Enabled" (DataCollectionObject), a BooleanValue
  • "Start" (Probe), a TimeValue
  • "Stop" (Probe), a TimeValue

You can set those attributes right at object creation with this method:

Ptr<DoubleProbe> myprobe = CreateObjectWithAttributes<DoubleProbe> (
    "Name", StringValue ("myprobe"),
    "Enabled", BooleanValue (false),
    "Start", TimeValue (Seconds (100.0)),
    "Stop", TimeValue (Seconds (1000.0)));

Start and Stop are Time variables that determine the interval of action of the Probe. The Probe will only output data if the current time of the Simulation is inside that interval. The special time value of 0 seconds for Stop will disable this attribute (i.e. keep the Probe on for the whole simulation). Enabled is a flag that turns the Probe on or off, and it must be set to true for the Probe to export data. The Name is the object's name in the DCF framework.


💖 Importing and exporting data 💖

ns-3 trace sources are strongly typed, so the mechanisms for hooking Probes to a trace source and for exporting data belong to its subclasses. For instance, the default distribution of ns-3 provides a class DoubleProbe that's designed to hook to a trace source exporting a double value. We'll next detail the operation of the DoubleProbe, and then talk about how other Probe classes may be defined by the user.


🦋 DoubleProbe Overview 🦋

The DoubleProbe connects to a double-valued ns-3 trace source, and itself exports a different double-valued ns-3 trace source. Iconic honestly.

The following code, drawn from src/stats/examples/double-probe-example.cc, shows the basic operations of plumbing the DoubleProbe into a simulation, where it's probing a Counter exported by an emitter object (class Emitter):

Ptr<Emitter> emitter = CreateObject<Emitter> ();
Names::Add ("/Names/Emitter", emitter);
...

Ptr<DoubleProbe> probe1 = CreateObject<DoubleProbe> ();

// Connect the probe to the emitter's Counter
bool connected = probe1->ConnectByObject ("Counter", emitter);

The following code is probing the same Counter exported by the same emitter object. This DoubleProbe, however, is using a path in the configuration namespace to make the connection. Note that the emitter registered itself in the configuration namespace after it was created — otherwise, the ConnectByPath would not work:

Ptr<DoubleProbe> probe2 = CreateObject<DoubleProbe> ();

// Note, no return value is checked here
probe2->ConnectByPath ("/Names/Emitter/Counter");

The next DoubleProbe shown below will have its value set using its path in the configuration namespace. Note that this time the DoubleProbe registered itself in the configuration namespace after it was created:

Ptr<DoubleProbe> probe3 = CreateObject<DoubleProbe> ();
probe3->SetName ("StaticallyAccessedProbe");

// We must add it to the config database
Names::Add ("/Names/Probes", probe3->GetName (), probe3);

The emitter's Count() function is now able to set the value for this DoubleProbe like this:

void
Emitter::Count (void)
{
  ...
  m_counter += 1.0;
  DoubleProbe::SetValueByPath ("/Names/StaticallyAccessedProbe", m_counter);
  ...
}

The above example shows how the code calling the Probe doesn't have to have an explicit reference to the Probe — it can just direct the value setting through the Config namespace. This is similar in functionality to the Stat::Put method introduced by the ns2measure paper [Cic06], and it lets users temporarily insert Probe statements like printf statements within existing ns-3 models. Note that in order to be able to use the DoubleProbe in this example like this, 2 things were necessary:

  1. the stats module header file was included in the example .cc file
  2. the example was made dependent on the stats module in its wscript file.

Analogous things need to be done in order to add other Probes in other places in the ns-3 code base.

The values for the DoubleProbe can also be set using the function DoubleProbe::SetValue(), while the values for the DoubleProbe can be gotten using the function DoubleProbe::GetValue().

The DoubleProbe exports double values in its "Output" trace source; a downstream object can hook a trace sink (NotifyViaProbe) to this as follows:

connected = probe1->TraceConnect ("Output", probe1->GetName (), MakeCallback (&NotifyViaProbe));

✨ Other probes ✨

Besides the DoubleProbe, the following Probes are also available:

  • Uinteger8Probe connects to an ns-3 trace source exporting an uint8_t.
  • Uinteger16Probe connects to an ns-3 trace source exporting an uint16_t.
  • Uinteger32Probe connects to an ns-3 trace source exporting an uint32_t.
  • PacketProbe connects to an ns-3 trace source exporting a packet.
  • ApplicationPacketProbe connects to an ns-3 trace source exporting a packet and a socket address.
  • Ipv4PacketProbe connects to an ns-3 trace source exporting a packet, an IPv4 object, and an interface.

🎀 Creating new Probe types 🎀

To create a new Probe type, you need to perform the following steps:

  • Be sure that your new Probe class is derived from the Probe base class.
  • Be sure that the pure virtual functions that your new Probe class inherits from the Probe base class are implemented.
  • Find an existing Probe class that uses a trace source that is closest in type to the type of trace source your Probe will be using.
  • Copy that existing Probe class's header file (.h) and implementation file (.cc) to two new files with names matching your new Probe.
  • Replace the types, arguments, and variables in the copied files with the appropriate type for your Probe.
  • Make necessary modifications to make the code compile and to make it behave as you would like.

💜 Examples 💜

Two examples will be discussed in detail here:

  • Double Probe Example
  • IPv4 Packet Plot Example

🌈 Double Probe Example 🌈

The double probe example has been discussed previously. The example program can be found in src/stats/examples/double-probe-example.cc. To summarize what occurs in this program: there's an emitter that exports a counter that increments according to a Poisson process. In particular, two ways of emitting data are shown:

  1. through a traced variable hooked to one Probe:
TracedValue<double> m_counter;  // normally this would be integer type
  1. through a counter whose value is posted to a second Probe, referenced by its name in the Config system:
void
Emitter::Count (void)
{
  NS_LOG_FUNCTION (this);
  NS_LOG_DEBUG ("Counting at " << Simulator::Now ().GetSeconds ());
  m_counter += 1.0;
  DoubleProbe::SetValueByPath ("/Names/StaticallyAccessedProbe", m_counter);
  Simulator::Schedule (Seconds (m_var->GetValue ()), &Emitter::Count, this);
}

Let's look at the Probe more carefully. Probes can receive their values in a multiple of ways:

  1. by the Probe accessing the trace source directly and connecting a trace sink to it
  2. by the Probe accessing the trace source through the config namespace and connecting a trace sink to it
  3. by the calling code explicitly calling the Probe's SetValue() method
  4. by the calling code explicitly calling SetValueByPath ("/path/through/Config/namespace", …)

The first two techniques are expected to be the most common. Also in the example, the hooking of a normal callback function is shown, as is typically done in ns-3. This callback function is not associated with a Probe object. We'll call this case 0) below.

// This is a function to test hooking a raw function to the trace source
void
NotifyViaTraceSource (std::string context, double oldVal, double newVal)
{
  NS_LOG_DEBUG ("context: " << context << " old " << oldVal << " new " << newVal);
}

First, the emitter needs to be setup:

Ptr<Emitter> emitter = CreateObject<Emitter> ();
Names::Add ("/Names/Emitter", emitter);
// The Emitter object is not associated with an ns-3 node, so
// it won't get started automatically, so we need to do this ourselves
Simulator::Schedule (Seconds (0.0), &Emitter::Start, emitter);

okay so the various DoubleProbes in this example all poke at the emitter, and here's literally how each one does it 💅

Case 0):

// The below shows typical functionality without a probe
// (connect a sink function to a trace source)
//
connected = emitter->TraceConnect ("Counter", "sample context", MakeCallback (&NotifyViaTraceSource));
NS_ASSERT_MSG (connected, "Trace source not connected");

case 1):

//
// Probe1 will be hooked directly to the Emitter trace source object
//

// probe1 will be hooked to the Emitter trace source
Ptr<DoubleProbe> probe1 = CreateObject<DoubleProbe> ();
// the probe's name can serve as its context in the tracing
probe1->SetName ("ObjectProbe");

// Connect the probe to the emitter's Counter
connected = probe1->ConnectByObject ("Counter", emitter);
NS_ASSERT_MSG (connected, "Trace source not connected to probe1");

case 2):

//
// Probe2 will be hooked to the Emitter trace source object by
// accessing it by path name in the Config database
//

// Create another similar probe; this will hook up via a Config path
Ptr<DoubleProbe> probe2 = CreateObject<DoubleProbe> ();
probe2->SetName ("PathProbe");

// Note, no return value is checked here
probe2->ConnectByPath ("/Names/Emitter/Counter");

case 4) (case 3 is not shown in this example):

//
// Probe3 will be called by the emitter directly through the
// static method SetValueByPath().
//
Ptr<DoubleProbe> probe3 = CreateObject<DoubleProbe> ();
probe3->SetName ("StaticallyAccessedProbe");
// We must add it to the config database
Names::Add ("/Names/Probes", probe3->GetName (), probe3);

and finally, bestie, the example shows how the probes get hooked up so they actually produce output ✨

// The probe itself should generate output.  The context that we provide
// to this probe (in this case, the probe name) will help to disambiguate
// the source of the trace
connected = probe3->TraceConnect ("Output",
                                  "/Names/Probes/StaticallyAccessedProbe/Output",
                                  MakeCallback (&NotifyViaProbe));
NS_ASSERT_MSG (connected, "Trace source not .. connected to probe3 Output");

and this next callback is hooked to the Probe in the example just so you can see it happening — normally you'd hook the Probe to a Collector object instead 👑

// This is a function to test hooking it to the probe output
void
NotifyViaProbe (std::string context, double oldVal, double newVal)
{
  NS_LOG_DEBUG ("context: " << context << " old " << oldVal << " new " << newVal);
}

🦋 IPv4 Packet Plot Example 🦋

so the IPv4 packet plot example is built on the fifth.cc example from the ns-3 Tutorial, and you'll find it at src/stats/examples/ipv4-packet-plot-example.cc 📍

         node 0                 node 1
   +----------------+    +----------------+
   |    ns-3 TCP    |    |    ns-3 TCP    |
   +----------------+    +----------------+
   |    10.1.1.1    |    |    10.1.1.2    |
   +----------------+    +----------------+
   | point-to-point |    | point-to-point |
   +----------------+    +----------------+
           |                     |
           +---------------------+

we're only looking at the Probe here, because it's the moment where Probes get to show off that they can unpack values out of structures (in this case, packets 🎁) and report those values as trace source outputs, instead of just passing through the exact same type of data like a boring little pipe.

there's other stuff in this example that gets explained later in the documentation, don't worry. the two kinds of data exported here are the packet itself (Output) and a count of the number of bytes in the packet (OutputBytes).

TypeId
Ipv4PacketProbe::GetTypeId ()
{
  static TypeId tid = TypeId ("ns3::Ipv4PacketProbe")
    .SetParent<Probe> ()
    .AddConstructor<Ipv4PacketProbe> ()
    .AddTraceSource ( "Output",
                      "The packet plus its IPv4 object and interface that serve as the output for this probe",
                      MakeTraceSourceAccessor (&Ipv4PacketProbe::m_output))
    .AddTraceSource ( "OutputBytes",
                      "The number of bytes in the packet",
                      MakeTraceSourceAccessor (&Ipv4PacketProbe::m_outputBytes))
  ;
  return tid;
}

so when the Probe's trace sink catches a packet, if the Probe is enabled it outputs the packet on its Output trace source — but it also outputs the number of bytes on the OutputBytes trace source. two for one, we love a multitasker 🫶

void
Ipv4PacketProbe::TraceSink (Ptr<const Packet> packet, Ptr<Ipv4> ipv4, uint32_t interface)
{
  NS_LOG_FUNCTION (this << packet << ipv4 << interface);
  if (IsEnabled ())
    {
      m_packet    = packet;
      m_ipv4      = ipv4;
      m_interface = interface;
      m_output (packet, ipv4, interface);

      uint32_t packetSizeNew = packet->GetSize ();
      m_outputBytes (m_packetSizeOld, packetSizeNew);
      m_packetSizeOld = packetSizeNew;
    }
}

🌸 References 🌸

  • [Cic06] — Claudio Cicconetti, Enzo Mingozzi, Giovanni Stea, “An Integrated Framework for Enabling Effective Data Collection and Statistical Analysis with ns2, Workshop on ns-2 (WNS2), Pisa, Italy, October 2006.

🎀 Collectors 🎀

this section is a lil placeholder rn — it's meant to detail the functionalities that the Collector class gives to an ns-3 simulation, plus examples of how to code them in a program.

⚠️ heads up bestie: as of ns-3.18, Collectors are still under development and not yet provided as part of the framework. so don't go looking for them like they're hiding in your build folder 💀

👑 Aggregators 👑

this section details the functionalities that the Aggregator class gives to an ns-3 simulation. it's for users who want to develop simulations with the ns-3 tools and use the Data Collection Framework — of which the Aggregator class is a part — to generate data output from their simulation's results. it's giving serious endgame energy 🔥


💜 Aggregator Overview 💜

an Aggregator object is supposed to be hooked to one or more trace sources so it can receive input. Aggregators are the end point of the data collected by the network of Probes and Collectors during the simulation. it's the Aggregator's whole job to take those values and transform them into their final output format — plain text files, spreadsheet files, plots, or databases 🗂️

typically, an aggregator is connected to one or more Collectors. that way, whenever the Collectors' trace sources export new values, the Aggregator can process the value so it can be used in the final output format where the data values will live after the simulation.

now, note these things about Aggregators, fr fr:

  • Aggregators may be dynamically turned on and off during the simulation with calls to Enable() and Disable(). like, for example, the aggregating of data may be turned off during the simulation warmup phase — which means those values won't be included in the final output medium. sneaky but valid 😌
  • Aggregators receive data from Collectors via callbacks. when a Collector is associated to an aggregator, a call to TraceConnect is made to establish the Aggregator's trace sink method as a callback.

to date, two Aggregators have been implemented:

  • GnuplotAggregator
  • FileAggregator

🌈 GnuplotAggregator 🌈

the GnuplotAggregator produces output files used to make gnuplots ✨

the GnuplotAggregator will create 3 different files at the end of the simulation:

  • a space separated gnuplot data file
  • a gnuplot control file
  • a shell script to generate the gnuplot

🦋 Creation 🦋

an object of type GnuplotAggregator gets created here so you can see exactly what needs to be done.

you declare a GnuplotAggregator in dynamic memory using the smart pointer class (Ptr<T>). to create a GnuplotAggregator in dynamic memory with smart pointers, you just call the ns-3 method CreateObject(). this code from src/stats/examples/gnuplot-aggregator-example.cc shows how 💅

string fileNameWithoutExtension = "gnuplot-aggregator";

// Create an aggregator.
Ptr<GnuplotAggregator> aggregator =
  CreateObject<GnuplotAggregator> (fileNameWithoutExtension);

the first argument for the constructor, fileNameWithoutExtension, is the name of the gnuplot related files to write with no extension. this GnuplotAggregator will create a space separated gnuplot data file named “gnuplot-aggregator.dat”, a gnuplot control file named “gnuplot-aggregator.plt”, and a shell script to generate the gnuplot named + “gnuplot-aggregator.sh”.

the gnuplot that gets created can have its key in 4 different locations:

  • No key
  • Key inside the plot (the default)
  • Key above the plot
  • Key below the plot

these are the gnuplot key location enum values allowed to specify the key's position:

enum KeyLocation {
  NO_KEY,
  KEY_INSIDE,
  KEY_ABOVE,
  KEY_BELOW
};

so if you wanted the key below instead of the default position of inside, you'd do this:

aggregator->SetKeyLocation(GnuplotAggregator::KEY_BELOW);

⭐ Examples ⭐

one example is getting the full detailed treatment here:

  • Gnuplot Aggregator Example

🎀 Gnuplot Aggregator Example 🎀

an example that exercises the GnuplotAggregator lives at src/stats/examples/gnuplot-aggregator-example.cc 📍

the following 2-D gnuplot was created using the example.

📸 [image] 2-D Gnuplot Created by gnuplot-aggregator-example.cc Example.

this code from the example shows how to construct the GnuplotAggregator exactly as we talked about above:

void Create2dPlot ()
{
  using namespace std;

  string fileNameWithoutExtension = "gnuplot-aggregator";
  string plotTitle                = "Gnuplot Aggregator Plot";
  string plotXAxisHeading         = "Time (seconds)";
  string plotYAxisHeading         = "Double Values";
  string plotDatasetLabel         = "Data Values";
  string datasetContext           = "Dataset/Context/String";

  // Create an aggregator.
  Ptr<GnuplotAggregator> aggregator =
    CreateObject<GnuplotAggregator> (fileNameWithoutExtension);

various GnuplotAggregator attributes are set, including the 2-D dataset that will be plotted 📈

          // Set the aggregator's properties.
          aggregator->SetTerminal ("png");
          aggregator->SetTitle (plotTitle);
          aggregator->SetLegend (plotXAxisHeading, plotYAxisHeading);

          // Add a data set to the aggregator.
          aggregator->Add2dDataset (datasetContext, plotDatasetLabel);

          // aggregator must be turned on
          aggregator->Enable ();

okay so next the 2-D values get calculated, and literally each one gets written individually to the GnuplotAggregator using the Write2d() function. one at a time. no shortcuts bestie 💅

            double time;
            double value;

            // Create the 2-D dataset.
            for (time = -5.0; time <= +5.0; time += 1.0)
              {
                // Calculate the 2-D curve
                //
                //                   2
                //     value  =  time   .
                //
                value = time * time;

                // Add this point to the plot.
                aggregator->Write2d (datasetContext, time, value);
              }

            // Disable logging of data for the aggregator.
            aggregator->Disable ();
          }

🦋 FileAggregator 🦋

the FileAggregator takes the values it receives and sends them to a file. that's it. that's the whole vibe. ✨

it can create 4 different types of files:

  • Formatted
  • Space separated — this one's the default btw 👀
  • Comma separated
  • Tab separated

Formatted files use C-style format strings and the sprintf() function to print their values in the file being written. iykyk.

✨ Creation ✨

so an object of type FileAggregator gets created here to show you what needs to be done. let's go.

you declare a FileAggregator in dynamic memory by using the smart pointer class (Ptr<T>). to create a FileAggregator in dynamic memory with smart pointers, you just need to call the ns-3 method CreateObject. the following code from src/stats/examples/file-aggregator-example.cc shows how to do this:

string fileName       = "file-aggregator-formatted-values.txt";

// Create an aggregator that will have formatted values.
Ptr<FileAggregator> aggregator =
  CreateObject<FileAggregator> (fileName, FileAggregator::FORMATTED);

the first argument for the constructor, filename, is the name of the file to write. the second argument, fileType, is type of file to write. this FileAggregator will create a file named "file-aggregator-formatted-values.txt" with its values printed as specified by fileType, i.e., formatted in this case. periodt. 💖

the following file type enum values are allowed:

enum FileType {
  FORMATTED,
  SPACE_SEPARATED,
  COMMA_SEPARATED,
  TAB_SEPARATED
};

🌸 Examples 🌸

one example will be discussed in detail here:

  • File Aggregator Example

🎀 File Aggregator Example 🎀

an example that exercises the FileAggregator can be found in src/stats/examples/file-aggregator-example.cc. go look, it's right there bestie.

the following text file with 2 columns of values separated by commas was created using the example.

-5,25
-4,16
-3,9
-2,4
-1,1
0,0
1,1
2,4
3,9
4,16
5,25

this code from the example shows how to construct the FileAggregator as was discussed above.

void CreateCommaSeparatedFile ()
{
  using namespace std;

  string fileName       = "file-aggregator-comma-separated.txt";
  string datasetContext = "Dataset/Context/String";

  // Create an aggregator.
  Ptr<FileAggregator> aggregator =
    CreateObject<FileAggregator> (fileName, FileAggregator::COMMA_SEPARATED);

FileAggregator attributes are set.

// aggregator must be turned on
aggregator->Enable ();

next, the 2-D values are calculated, and each one is individually written to the FileAggregator using the Write2d() function.

    double time;
    double value;

    // Create the 2-D dataset.
    for (time = -5.0; time <= +5.0; time += 1.0)
      {
        // Calculate the 2-D curve
        //
        //                   2
        //     value  =  time   .
        //
        value = time * time;

        // Add this point to the plot.
        aggregator->Write2d (datasetContext, time, value);
      }

    // Disable logging of data for the aggregator.
    aggregator->Disable ();
  }

the following text file with 2 columns of formatted values was also created using the example.

Time = -5.000e+00     Value = 25
Time = -4.000e+00     Value = 16
Time = -3.000e+00     Value = 9
Time = -2.000e+00     Value = 4
Time = -1.000e+00     Value = 1
Time = 0.000e+00      Value = 0
Time = 1.000e+00      Value = 1
Time = 2.000e+00      Value = 4
Time = 3.000e+00      Value = 9
Time = 4.000e+00      Value = 16
Time = 5.000e+00      Value = 25

this code from the example shows how to construct the FileAggregator as was discussed above.

void CreateFormattedFile ()
{
  using namespace std;

  string fileName       = "file-aggregator-formatted-values.txt";
  string datasetContext = "Dataset/Context/String";

  // Create an aggregator that will have formatted values.
  Ptr<FileAggregator> aggregator =
    CreateObject<FileAggregator> (fileName, FileAggregator::FORMATTED);

FileAggregator attributes are set, including the C-style format string to use.

// Set the format for the values.
aggregator->Set2dFormat ("Time = %.3e\tValue = %.0f");

// aggregator must be turned on
aggregator->Enable ();

next, the 2-D values are calculated, and each one is individually written to the FileAggregator using the Write2d() function.

    double time;
    double value;

    // Create the 2-D dataset.
    for (time = -5.0; time <= +5.0; time += 1.0)
      {
        // Calculate the 2-D curve
        //
        //                   2
        //     value  =  time   .
        //
        value = time * time;

        // Add this point to the plot.
        aggregator->Write2d (datasetContext, time, value);
      }

    // Disable logging of data for the aggregator.
    aggregator->Disable ();
  }

🧚 Adaptors 🧚

this section details the functionalities provided by the Adaptor class to an ns-3 simulation. it's meant for users who are interested in developing simulations with the ns-3 tools and using the Data Collection Framework — of which the Adaptor class is a part — to generate data output with their simulation's results. basically: you wanna get data out, this is your girl. 💜

Note: the term 'adaptor' may also be spelled 'adapter'. we chose the spelling aligned with the C++ standard. no cap.

🫶 Adaptor Overview 🫶

an Adaptor is used to make connections between different types of DCF objects.

to date, one Adaptor has been implemented:

  • TimeSeriesAdaptor

⭐ Time Series Adaptor ⭐

the TimeSeriesAdaptor lets Probes connect directly to Aggregators without needing any Collector in between. iconic behaviour honestly.

both of the implemented DCF helpers utilize TimeSeriesAdaptors in order to take probed values of different types and output the current time plus the value with both converted to doubles.

the role of the TimeSeriesAdaptor class is that of an adaptor, which takes raw-valued probe data of different types and outputs a tuple of two double values. the first is a timestamp, which may be set to different resolutions (e.g. Seconds, Milliseconds, etc.) in the future but which is presently hardcoded to Seconds. the second is the conversion of a non-double value to a double value (possibly with loss of precision). ⚠️ yes, precision can get a little lost in translation, be aware.


💀 Scope/Limitations 💀

this section discusses the scope and limitations of the Data Collection Framework. real talk time.

currently, only these Probes have been implemented in DCF:

  • BooleanProbe
  • DoubleProbe
  • Uinteger8Probe
  • Uinteger16Probe
  • Uinteger32Probe
  • TimeProbe
  • PacketProbe
  • ApplicationPacketProbe
  • Ipv4PacketProbe

currently, no Collectors are available in the DCF, although a BasicStatsCollector is under development. so close yet so far 😭

currently, only these Aggregators have been implemented in DCF:

  • GnuplotAggregator
  • FileAggregator

currently, only this Adaptor has been implemented in DCF:

  • Time-Series Adaptor.

🔥 Future Work 🔥

this section discusses the future work to be done on the Data Collection Framework. manifesting tbh ✨

here are some things that still need to be done:

  • Hook up more trace sources in ns-3 code to get more values out of the simulator.
  • Implement more types of Probes than there currently are.
  • Implement more than just the single current 2-D Collector, BasicStatsCollector.
  • Implement more Aggregators.
  • Implement more than just Adaptors.

🌈 STATISTICAL FRAMEWORK 🌈

this chapter outlines work on simulation data collection and the statistical framework for ns-3.

the source code for the statistical framework lives in the directory src/stats. go peek.

👑 Goals 👑

primary objectives for this effort are the following:

  • Provide functionality to record, calculate, and present data and statistics for analysis of network simulations.
  • Boost simulation performance by reducing the need to generate extensive trace logs in order to collect data.
  • Enable simulation control via online statistics, e.g. terminating simulations or repeating trials.

derived sub-goals and other target features include the following:

  • Integration with the existing ns-3 tracing system as the basic instrumentation framework of the internal simulation engine, e.g. network stacks, net devices, and channels.
  • Enabling users to utilize the statistics framework without requiring use of the tracing system.
  • Helping users create, aggregate, and analyze data over multiple trials.
  • Support for user created instrumentation, e.g. of application specific events and measures.
  • Low memory and CPU overhead when the package is not in use.
  • Leveraging existing analysis and output tools as much as possible. the framework may provide some basic statistics, but the focus is on collecting data and making it accessible for manipulation in established tools.
  • Eventual support for distributing independent replications is important but not included in the first round of features.

🍓 Overview 🍓

the statistics framework includes the following features:

  • The core framework and two basic data collectors: A counter, and a min/max/avg/total observer.
  • Extensions of those to easily work with times and packets.
  • Plaintext output formatted for OMNet++.
  • Database output using SQLite, a standalone, lightweight, high performance SQL engine.
  • Mandatory and open ended metadata for describing and working with runs.
  • An example based on the notional experiment of examining the properties of NS-3’s default ad hoc WiFi performance. It incorporates the following:
    • Constructs of a two node ad hoc WiFi network, with the nodes a parameterized distance apart.
    • UDP traffic source and sink applications with slightly different behavior and measurement hooks than the stock classes.
    • Data collection from the NS-3 core via existing trace signals, in particular data on frames transmitted and received by the WiFi MAC objects.
    • Instrumentation of custom applications by connecting new trace signals to the stat framework, as well as via direct updates. Information is recorded about total packets sent and received, bytes transmitted, and end-to-end delay.
    • An example of using packet tags to track end-to-end delay.
    • A simple control script which runs a number of trials of the experiment at varying distances and queries the resulting database to produce a graph using GNUPlot.

okay bestie, this whole example is literally the blueprint for the statistical framework, and it’s giving everything. 💅


✨ To-Do ✨

High priority items include:

  • Inclusion of online statistics code, e.g. for memory efficient confidence intervals.
  • Provisions in the data collectors for terminating runs, i.e. when a threshold or confidence is met.
  • Data collectors for logging samples over time, and output to the various formats.
  • Demonstrate writing simple cyclic event glue to regularly poll some value.

Each of those should prove straightforward to incorporate in the current framework.

like, these are the glow-up goals, and they’re all super doable in the current framework. slay. 💖


🌈 Approach 🌈

The framework is based around the following core principles:

  • One experiment trial is conducted by one instance of a simulation program, whether in parallel or serially.
  • A control script executes instances of the simulation, varying parameters as necessary.
  • Data is collected and stored for plotting and analysis using external scripts and existing tools.
  • Measures within the ns-3 core are taken by connecting the stat framework to existing trace signals.
  • Trace signals or direct manipulation of the framework may be used to instrument custom simulation code.

Those basic components of the framework and their interactions are depicted in the following figure. [image]

the way that this is structured? chef’s kiss. 💋


💖 Example 💖

This section goes through the process of constructing an experiment in the framework and producing data for analysis (graphs) from it, demonstrating the structure and API along the way.

bestie, we’re about to build the whole thing from scratch, and it’s giving main character energy. 🦋


❓ Question ❓

‘’What is the (simulated) performance of ns-3’s WiFi NetDevices (using the default settings)? How far apart can wireless nodes be in a simulation before they cannot communicate reliably?’’

  • Hypothesis: Based on knowledge of real life performance, the nodes should communicate reasonably well to at least 100m apart. Communication beyond 200m shouldn’t be feasible.

Although not a very common question in simulation contexts, this is an important property of which simulation developers should have a basic understanding. It is also a common study done on live hardware.

ngl, this is the kind of question that makes you go hmm, but it’s lowkey important. 🙈


🧪 Simulation Program 🧪

The first thing to do in implementing this experiment is developing the simulation program. The code for this example can be found in examples/stats/wifi-example-sim.cc. It does the following main steps.

okay so first up, we’re writing the sim program. it’s in examples/stats/wifi-example-sim.cc, and it’s about to do all the things. periodt. 💅

  • Declaring parameters and parsing the command line using ns3::CommandLine.
double distance = 50.0;
string format ("OMNet++");
string experiment ("wifi-distance-test");
string strategy ("wifi-default");
string runID;

CommandLine cmd (__FILE__);
cmd.AddValue("distance",   "Distance apart to place nodes (in meters).", distance);
cmd.AddValue("format",     "Format to use for data output.",             format);
cmd.AddValue("experiment", "Identifier for experiment.",                 experiment);
cmd.AddValue("strategy",   "Identifier for strategy.",                   strategy);
cmd.AddValue("run",        "Identifier for run.",                        runID);
cmd.Parse (argc, argv);

see how we’re setting up the defaults? distance is 50.0, format is OMNet++, etc. iconic. ✨

  • Creating nodes and network stacks using ns3::NodeContainer, ns3::WiFiHelper, and ns3::InternetStackHelper.
NodeContainer nodes;
nodes.Create(2);

WifiHelper wifi;
wifi.SetMac("ns3::AdhocWifiMac");
wifi.SetPhy("ns3::WifiPhy");
NetDeviceContainer nodeDevices = wifi.Install(nodes);

InternetStackHelper internet;
internet.Install(nodes);
Ipv4AddressHelper ipAddrs;
ipAddrs.SetBase("192.168.0.0", "255.255.255.0");
ipAddrs.Assign(nodeDevices);

two nodes, adhoc wifi mac, wifi phy, and an internet stack with 192.168.0.0/24. we love to see it. 💖

  • Positioning the nodes using ns3::MobilityHelper. By default the nodes have static mobility and won’t move, but must be positioned the given distance apart. There are several ways to do this; it is done here using ns3::ListPositionAllocator, which draws positions from a given list.
MobilityHelper mobility;
Ptr<ListPositionAllocator> positionAlloc =
  CreateObject<ListPositionAllocator>();
positionAlloc->Add(Vector(0.0, 0.0, 0.0));
positionAlloc->Add(Vector(0.0, distance, 0.0));
mobility.SetPositionAllocator(positionAlloc);
mobility.Install(nodes);

static mobility means they don’t move, but we place them distance apart. list position allocator is the MVP here. 👑

  • Installing a traffic generator and a traffic sink. The stock Applications could be used, but the example includes custom objects in src/test/test02-apps.(cc|h). These have a simple behavior, generating a given number of packets spaced at a given interval. As there is only one of each they are installed manually; for a larger set the ns3::ApplicationHelper class could be used. The commented-out Config::Set line changes the destination of the packets, set to broadcast by default in this example. Note that in general WiFi may have different performance for broadcast and unicast frames due to different rate control and MAC retransmission policies.
Ptr<Node> appSource = NodeList::GetNode(0);
Ptr<Sender> sender = CreateObject<Sender>();
appSource->AddApplication(sender);
sender->Start(Seconds(1));

Ptr<Node> appSink = NodeList::GetNode(1);
Ptr<Receiver> receiver = CreateObject<Receiver>();
appSink->AddApplication(receiver);
receiver->Start(Seconds(0));

//  Config::Set("/NodeList/*/ApplicationList/*/$Sender/Destination",
//              Ipv4AddressValue("192.168.0.2"));

note the commented out Config::Set, that’s for changing destination, but default is broadcast. and WiFi broadcast vs unicast? different rate control and MAC retransmission policies, so performance can differ. iykyk. 🔥

  • Configuring the data and statistics to be collected. The basic paradigm is that an ns3::DataCollector object is created to hold information about this particular run, to which observers and calculators are attached to actually generate data. Importantly, run information includes labels for the ‘’experiment’’, ‘’strategy’’, ‘’input’’, and ‘’run’’. These are used to later identify and easily group data from multiple trials.
    • The experiment is the study of which this trial is a member. Here it is on WiFi performance and distance.
    • The strategy is the code or parameters being examined in this trial. In this example it is fixed, but an obvious extension would be to investigate different WiFi bit rates, each of which would be a different strategy.
    • The input is the particular problem given to this trial. Here it is simply the distance between the two nodes.
    • The runID is a unique identifier for this trial with which it’s information is tagged for identification in later analysis. If no run ID is given the example program makes a (weak) run ID using the current time.

Those four pieces of metadata are required, but more may be desired. They may be added to the record using the ns3::DataCollector::AddMetadata() method.

DataCollector data;
data.DescribeRun(experiment, strategy, input, runID);
data.AddMetadata("author", "tjkopena");

these four labels are mandatory, but you can add more metadata with AddMetadata. here we add author as tjkopena. slay. 💅

Actual observation and calculating is done by ns3::DataCalculator objects, of which several different types exist. These are created by the simulation program, attached to reporting or sampling code, and then registered with the ns3::DataCollector so they will be queried later for their output. One easy observation mechanism is to use existing trace sources, for example to instrument objects in the ns-3 core without changing their code. Here a counter is attached directly to a trace signal in the WiFi MAC layer on the target node.

Ptr<PacketCounterCalculator> totalRx = CreateObject<PacketCounterCalculator>();
totalRx->SetKey("wifi-rx-frames");
Config::Connect("/NodeList/1/DeviceList/*/$ns3::WifiNetDevice/Rx",
                MakeCallback(&PacketCounterCalculator::FrameUpdate, totalRx));
data.AddDataCalculator(totalRx);

this is the part where we connect to existing trace signals without touching the core code. obsessed. 😍

Calculators may also be manipulated directly. In this example, a counter is created and passed to the traffic sink application to be updated when packets are received.

Ptr<CounterCalculator<> > appRx = CreateObject<CounterCalculator<> >();
appRx->SetKey("receiver-rx-packets");
receiver->SetCounter(appRx);
data.AddDataCalculator(appRx);

To increment the count, the sink’s packet processing code then calls one of the calculator’s update methods.

m_calc->Update();

direct manipulation? we love a hands-on moment. the sink calls update, and boom, count goes up. 🌸

The program includes several other examples as well, using both the primitive calculators such as ns3::CounterCalculator and those adapted for observing packets and times. In src/test/test02-apps.(cc|h) it also creates a simple custom tag which it uses to track end-to-end delay for generated packets, reporting results to a ns3::TimeMinMaxAvgTotalCalculator data calculator.

and there’s more in src/test/test02-apps.(cc|h), including a custom tag for end-to-end delay. it’s giving thorough. 🧚

  • Running the simulation, which is very straightforward once constructed.
Simulator::Run();

literally just Simulator::Run(); and we’re off. no cap. 🚀

  • Generating either OMNet++ or SQLite output, depending on the command line arguments. To do this a ns3::DataOutputInterface object is created and configured. The specific type of this will determine the output format. This object is then given the ns3::DataCollector object which it interrogates to produce the output.
Ptr<DataOutputInterface> output;
if (format == "OMNet++") {
  NS_LOG_INFO("Creating OMNet++ formatted data output.");
  output = CreateObject<OmnetDataOutput>();
} else {
#   ifdef STAT_USE_DB
    NS_LOG_INFO("Creating SQLite formatted data output.");
    output = CreateObject<SqliteDataOutput>();
#   endif
}

output->Output(data);

depending on format arg, we either do OMNet++ or SQLite (if STAT_USE_DB is defined). then output->Output(data) does the magic. periodt. 💅

  • Freeing any memory used by the simulation. This should come at the end of the main function for the example.
Simulator::Destroy();

and finally, clean up with Simulator::Destroy(); at the end of main. bestie, always clean up your memory. 💅

🌸 Logging 🌸

Wanna see literally everything the example program, the applications, and the stat framework are doing? Bestie, set the NS_LOG variable and buckle up. This next one gives you copious output from all three, no cap:

$ export NS_LOG=WiFiDistanceExperiment:WiFiDistanceApps
Heads up though — this slows down the simulation extraordinarily. Like, it's giving molasses. 💀

✨ Sample Output ✨

Compile it, run the test program, and it'll append OMNet++ formatted output to data.sca that looks a lil something like this:

run run-1212239121

attr experiment "wifi-distance-test"
attr strategy "wifi-default"
attr input "50"
attr description ""

attr "author" "tjkopena"

scalar wifi-tx-frames count 30
scalar wifi-rx-frames count 30
scalar sender-tx-packets count 30
scalar receiver-rx-packets count 30
scalar tx-pkt-size count 30
scalar tx-pkt-size total 1920
scalar tx-pkt-size average 64
scalar tx-pkt-size max 64
scalar tx-pkt-size min 64
scalar delay count 30
scalar delay total 5884980ns
scalar delay average 196166ns
scalar delay max 196166ns
scalar delay min 196166ns

Look at those tidy lil numbers, so satisfying 🫶


🎀 Control Script 🎀

To automate collecting data across a bunch of different inputs (distances, in this case), there's a simple Bash script doing the heavy lifting. You'll find it at examples/stats/wifi-example-db.sh, and it's meant to be run from the examples/stats/ directory — don't be out here running it from anywhere else, bestie.

So what does it do? It runs through a whole set of distances and collects the results into an SQLite database. And at each distance it does five trials, so you get a better picture of what performance to actually expect. The whole experiment takes only a few dozen seconds to run even on a low end machine — because there's no output during the simulation and only a lil bit of traffic gets generated. Efficiency queen. 👑

#!/bin/sh

DISTANCES="25 50 75 100 125 145 147 150 152 155 157 160 162 165 167 170 172 175 177 180"
TRIALS="1 2 3 4 5"

echo WiFi Experiment Example

if [ -e data.db ]
then
  echo Kill data.db?
  read ANS
  if [ "$ANS" = "yes" -o "$ANS" = "y" ]
  then
    echo Deleting database
    rm data.db
  fi
fi

for trial in $TRIALS
do
  for distance in $DISTANCES
  do
    echo Trial $trial, distance $distance
    ./bin/test02 --format=db --distance=$distance --run=run-$distance-$trial
  done
done

💅 Analysis and Conclusion 💅

Once every trial is done, the script runs a simple SQL query over the database using the SQLite command line program. The query computes the average packet loss in each set of trials associated with each distance. Now — it does not take into account different strategies, but the info is all sitting there in the database, so you could make some simple extensions and do that yourself. The collected data then gets passed to GNUPlot for graphing. It's giving pipeline. 🌈

CMD="select exp.input,avg(100-((rx.value*100)/tx.value)) \
    from Singletons rx, Singletons tx, Experiments exp \
    where rx.run = tx.run AND \
          rx.run = exp.run AND \
          rx.name='receiver-rx-packets' AND \
          tx.name='sender-tx-packets' \
    group by exp.input \
    order by abs(exp.input) ASC;"

sqlite3 -noheader data.db "$CMD" > wifi-default.data
sed -i "s/|/   /" wifi-default.data
gnuplot wifi-example.gnuplot

The GNUPlot script at examples/stats/wifi-example.gnuplot just defines the output format and some basic formatting for the graph. Simple, clean, iconic:

set terminal postscript portrait enhanced lw 2 "Helvetica" 14

set size 1.0, 0.66

#-------------------------------------------------------
set out "wifi-default.eps"
#set title "Packet Loss Over Distance"
set xlabel "Distance (m) --- average of 5 trials per point"
set xrange [0:200]
set ylabel "% Packet Loss"
set yrange [0:110]

plot "wifi-default.data" with lines title "WiFi Defaults"

🌟 End Result 🌟

And the resulting graph? It provides no evidence that the default WiFi model's performance is necessarily unreasonable, and it lends some confidence to an at least token faithfulness to reality. But more importantly — and this is the real slay — this whole simple investigation has been carried all the way through using the statistical framework. Success! 🎉 [image]


🔥 REALTIME 🔥

So ns-3 has been designed for integration into testbed and virtual machine environments. To integrate with real network stacks and emit/consume packets, you need a real-time scheduler to try to lock the simulation clock with the hardware clock. And here we're describing one component of that whole situation: the RealTime scheduler.

The whole purpose of the realtime scheduler is to cause the progression of the simulation clock to occur synchronously with respect to some external time base. Because without an external time base (wall clock)? Simulation time just jumps instantly from one simulated time to the next. No chill whatsoever.

🦋 Behavior 🦋

When you're using a non-realtime scheduler (which is the default in ns-3), the simulator advances the simulation time to the next scheduled event. And during event execution, simulation time is frozen. With the realtime scheduler, the behavior is similar from the perspective of simulation models (i.e., simulation time is still frozen during event execution), but between events, the simulator will attempt to keep the simulation clock aligned with the machine clock.

So when an event finishes executing and the scheduler moves to the next event, it compares the next event execution time with the machine clock. If that next event is scheduled for a future time, the simulator sleeps until that realtime is reached, and then executes the next event. It's giving patience, honestly.

Now, it may happen that — due to the processing inherent in the execution of simulation events — the simulator cannot keep up with realtime. And in such a case, it is up to you, the user configuration, what to do. There are two ns-3 attributes that govern the behavior:

The first is ns3::RealTimeSimulatorImpl::SynchronizationMode. The two entries possible for this attribute are BestEffort (the default) or HardLimit.

  • In "BestEffort" mode, the simulator will just try to catch up to realtime by executing events until it reaches a point where the next event is in the (realtime) future, or else the simulation ends. In BestEffort mode, then, it is possible for the simulation to consume more time than the wall clock time. 😭
  • The other option, "HardLimit", will cause the simulation to abort if the tolerance threshold is exceeded.

That threshold attribute is ns3::RealTimeSimulatorImpl::HardLimit and the default is 0.1 seconds.

Now there's also a different mode of operation — one in which simulated time is not frozen during an event execution. This mode of realtime simulation was implemented but removed from the ns-3 tree because of questions of whether it would be useful. So if you're interested in a realtime simulator for which simulation time does not freeze during event execution (i.e., every call to Simulator::Now() returns the current wall clock time, not the time at which the event started executing), please contact the ns-developers mailing list. They'd love to hear from you fr fr. 💌

💖 Usage 💖

Using the realtime simulator is straightforward, from a scripting perspective. You just need to set the attribute SimulatorImplementationType to the Realtime simulator, like so:

GlobalValue::Bind ("SimulatorImplementationType",
  StringValue ("ns3::RealtimeSimulatorImpl"));

There's a script at examples/realtime/realtime-udp-echo.cc that has an example of how to configure the realtime behavior. Try it:

$ ./waf --run realtime-udp-echo

And whether the simulator will work in a best effort or hard limit policy fashion is governed by the attributes we explained in the previous section. Iykyk.

🧚 Implementation 🧚

The implementation lives in the following files:

  • src/core/model/realtime-simulator-impl.{cc,h}
  • src/core/model/wall-clock-synchronizer.{cc,h}

So in order to create a realtime scheduler, to a first approximation you just want to cause simulation time jumps to consume real time. We propose doing this using a combination of sleep- and busy- waits.

Sleep-waits cause the calling process (thread) to yield the processor for some amount of time. And even though this specified amount of time can be passed to nanosecond resolution, it's actually converted to an OS-specific granularity. In Linux, that granularity is called a Jiffy. Typically this resolution is insufficient for our needs (on the order of a ten milliseconds), so we round down and sleep for some smaller number of Jiffies. Then the process is awakened after the specified number of Jiffies has passed. At that time, we have some residual time to wait. And this time is generally smaller than the minimum sleep time, so we busy-wait for the remainder of the time. Which means the thread just sits in a for loop consuming cycles until the desired time arrives. 💀

But after the combination of sleep- and busy-waits, the elapsed realtime (wall) clock should agree with the simulation time of the next event, and the simulation proceeds. Chef's kiss. 👨‍🍳💋

🫶 HELPERS 🫶

ok so bestie, remember all that stuff we just talked about — smart pointers for reference-counted memory management, attributes, namespaces, callbacks, the whole vibe? 💅 Yeah. All of that is the low-level API, and if you're the kind of girl who wants to wire up ns-3 objects with fine granularity, she's everything you need. But. A whole simulation written purely at that level? It's giving unhinged. Long. Tedious. Your fingers will simply file a complaint. 😭

So the ns-3 girlies overlaid a separate thing on top of the core ns-3 API called the “helper API”. If you've read the ns-3 tutorial you already know her — she's literally the first API new users get introduced to. 🎀 In this chapter we're doing a little design-philosophy moment: helper API vs. low-level API, side by side. And ngl, once you become a heavy ns-3 user you'll be bouncing between both APIs even inside the same program. It's a lifestyle. 🦋

The helper API has a few goals, and they are lowkey iconic:

  1. The rest of src/ has no dependencies on the helper API; anything that can be done with the helper API can be coded also at the low-level API. Periodt. 💖
  2. Containers: Often simulations will need to do a number of identical actions to groups of objects. The helper API makes heavy use of containers of similar objects to which similar or identical operations can be performed. Group chat energy, but for objects. 👑
  3. The helper API is not generic; it does not strive to maximize code reuse. So, programming constructs such as polymorphism and templates that achieve code reuse are not as prevalent. For instance, there are separate CsmaNetDevice helpers and PointToPointNetDevice helpers but they do not derive from a common NetDevice base class. No shared ancestry, just vibes. 🙈
  4. The helper API typically works with stack-allocated (vs. heap-allocated) objects. For some programs, ns-3 users may not need to worry about any low level Object Create or Ptr handling; they can make do with containers of objects and stack-allocated helpers that operate on them. ✨

The whole point of the helper API is making ns-3 programs easier to write and read, without taking away the power of the low-level interface. That's the assignment and she ate. 💅 The rest of this chapter is just examples of the programming conventions of the helper API, so buckle up.


🛠️ UTILITIES 🛠️

print-introspected-doxygen is the girl who generates doxygen documentation using various TypeIds defined throughout the ns-3 source code. The tool returns the various config paths, attributes, trace sources, etc. for the various files in ns-3. She's thorough. She's nosy in the best way. 🎀

Invocation ✨

This tool is run automatically by the build system when generating the Doxygen API docs, so you don't normally have to run it by hand. (Not me doing unpaid labor for the build system 😭)

However, since it does give a fair bit of information about TypeIds it can be useful to run from the command line and search for specific information.

To run it, simply open terminal and type

$ ./waf --run print-introspected-doxygen

This will give all the output, formatted for Doxygen, which can be viewed in a text editor.

One way to use this is to capture it to a file:

$ ./waf --run print-introspected-doxygen > doc.html

Some users might prefer to use tools like grep to locate the required piece of information from the documentation instead of using an editor. For such uses-cases and more, print-introspected-doxygen can return plain text:

$ ./waf --run "print-introspected-doxygen --output-text"

(Note the quotes around the inner command and options. 🫶 Don't forget them, bestie, or it's over.)

$ ./waf –run “print-introspected-doxygen –output-text” | grep “hello”

This will output the following:

* HelloInterval: HELLO messages emission interval.
* DeletePeriod: DeletePeriod is intended to provide an upper bound on the time for which an upstream node A can have a neighbor B as an active next hop for destination D, while B has invalidated the route to D. = 5 * max (HelloInterval, ActiveRouteTimeout)
* AllowedHelloLoss: Number of hello messages which may be loss for valid link.
* EnableHello: Indicates whether a hello messages enable.
* HelloInterval: HELLO messages emission interval.
* HelloInterval: HELLO messages emission interval.
* DeletePeriod: DeletePeriod is intended to provide an upper bound on the time for which an upstream node A can have a neighbor B as an active next hop for destination D, while B has invalidated the route to D. = 5 * max (HelloInterval, ActiveRouteTimeout)
* AllowedHelloLoss: Number of hello messages which may be loss for valid link.
* EnableHello: Indicates whether a hello messages enable.
* HelloInterval: HELLO messages emission interval.

(yes the repeats are real, she said what she said 💀)


Bench-simulator ⏱️

This tool is used to benchmark the scheduler algorithms used in ns-3. It's the gym-rat of utilities. 🔥

Command-line Arguments 🎛️

$ ./waf --run "bench-simulator --help"

Program Options:
    --cal:    use CalendarSheduler [false]
    --heap:   use HeapScheduler [false]
    --list:   use ListSheduler [false]
    --map:    use MapScheduler (default) [true]
    --debug:  enable debugging output [false]
    --pop:    event population size (default 1E5) [100000]
    --total:  total number of events to run (default 1E6) [1000000]
    --runs:   number of runs (default 1) [1]
    --file:   file of relative event times []
    --prec:   printed output precision [6]

You can change the Scheduler being benchmarked by passing the appropriate flags, for example if you want to benchmark the CalendarScheduler pass –cal to the program. ✨

The default total number of events, runs or population size can be overridden by passing –total=value, –runs=value and –pop=value respectively.

If you want to use event distribution which is stored in a file, you can pass the file option by –file=FILE_NAME.

–prec can be used to change the output precision value and –debug as the name suggests enables debugging. 👑

Invocation 💫

To run it, simply open the terminal and type

$ ./waf --run bench-simulator

It will show something like this depending upon the scheduler being benchmarked:

ns3-dev-bench-simulator-debug:
ns3-dev-bench-simulator-debug: scheduler: ns3::MapScheduler
ns3-dev-bench-simulator-debug: population: 100000
ns3-dev-bench-simulator-debug: total events: 1000000
ns3-dev-bench-simulator-debug: runs: 1
ns3-dev-bench-simulator-debug: using default exponential distribution

Run        Inititialization:                   Simulation:
            Time (s)    Rate (ev/s) Per (s/ev)  Time (s)    Rate (ev/s) Per (s/ev)
----------- ----------- ----------- ----------- ----------- ----------- -----------
(prime)     0.4         250000      4e-06       1.84        543478      1.84e-06
0           0.15        666667      1.5e-06     1.86        537634      1.86e-06

Suppose we had to benchmark CalendarScheduler instead, we would have written

$ ./waf --run "bench-simulator --cal"

And the output would look something like this:

ns3-dev-bench-simulator-debug:
ns3-dev-bench-simulator-debug: scheduler: ns3::CalendarScheduler
ns3-dev-bench-simulator-debug: population: 100000
ns3-dev-bench-simulator-debug: total events: 1000000
ns3-dev-bench-simulator-debug: runs: 1
ns3-dev-bench-simulator-debug: using default exponential distribution

Run        Inititialization:                   Simulation:
            Time (s)    Rate (ev/s) Per (s/ev)  Time (s)    Rate (ev/s) Per (s/ev)
----------- ----------- ----------- ----------- ----------- ----------- -----------
(prime)     1.19        84033.6     1.19e-05    32.03       31220.7     3.203e-05
0           0.99        101010      9.9e-06     31.22       32030.7     3.122e-05

🌸 and that's the scheduler tea, bestie — more coming, don't go anywhere 💖

🌸 MAKING PLOTS USING THE GNUPLOT CLASS 🌸

okay bestie so there are literally 2 common ways to make a plot with ns-3 and gnuplot (http://www.gnuplot.info) and we're about to spill all the tea 💅

  1. Make a gnuplot control file using ns-3's Gnuplot class.
  2. Make a gnuplot data file using values generated by ns-3.

This section is all about method 1, meaning it's about how to make a plot using ns-3's Gnuplot class. If method 2 is more your vibe, go peek at the "A Real Example" subsection under the "Tracing" section in the ns-3 Tutorial. 🦋


✨ Creating Plots Using the Gnuplot Class ✨

These steps must be taken to make a plot using ns-3's Gnuplot class, no skipping fr fr:

  1. Modify your code so that it uses the Gnuplot class and its functions.
  2. Run your code so that it creates a gnuplot control file.
  3. Call gnuplot with the name of the gnuplot control file.
  4. View the graphics file that was produced in your favorite graphics viewer.

For the deets on step 1, look at the code from the example plots we chat about below. 👑


💖 An Example Program that Uses the Gnuplot Class 💖

An example program that uses ns-3's Gnuplot class lives right here:

src/stats/examples/gnuplot-example.cc

To run this example, do the following:

$ ./waf --run src/stats/examples/gnuplot-example

This should produce the following gnuplot control files, slay:

plot-2d.plt
plot-2d-with-error-bars.plt
plot-3d.plt

To process these gnuplot control files, do the following:

$ gnuplot plot-2d.plt
$ gnuplot plot-2d-with-error-bars.plt
$ gnuplot plot-3d.plt

This should produce the following graphics files:

plot-2d.png
plot-2d-with-error-bars.png
plot-3d.png

You can view these graphics files in your favorite graphics viewer. If you have gimp installed on your machine, for example, you can do this:

$ gimp plot-2d.png
$ gimp plot-2d-with-error-bars.png
$ gimp plot-3d.png

🎀 An Example 2-Dimensional Plot 🎀

The following 2-Dimensional plot

[image]

was created using the following code from gnuplot-example.cc:

using namespace std;

string fileNameWithNoExtension = "plot-2d";
string graphicsFileName        = fileNameWithNoExtension + ".png";
string plotFileName            = fileNameWithNoExtension + ".plt";
string plotTitle               = "2-D Plot";
string dataTitle               = "2-D Data";

// Instantiate the plot and set its title.
Gnuplot plot (graphicsFileName);
plot.SetTitle (plotTitle);

// Make the graphics file, which the plot file will create when it
// is used with Gnuplot, be a PNG file.
plot.SetTerminal ("png");

// Set the labels for each axis.
plot.SetLegend ("X Values", "Y Values");

// Set the range for the x axis.
plot.AppendExtra ("set xrange [-6:+6]");

// Instantiate the dataset, set its title, and make the points be
// plotted along with connecting lines.
Gnuplot2dDataset dataset;
dataset.SetTitle (dataTitle);
dataset.SetStyle (Gnuplot2dDataset::LINES_POINTS);

double x;
double y;

// Create the 2-D dataset.
for (x = -5.0; x <= +5.0; x += 1.0)
  {
    // Calculate the 2-D curve
    //
    //            2
    //     y  =  x   .
    //
    y = x * x;

    // Add this point.
    dataset.Add (x, y);
  }

// Add the dataset to the plot.
plot.AddDataset (dataset);

// Open the plot file.
ofstream plotFile (plotFileName.c_str());

// Write the plot file.
plot.GenerateOutput (plotFile);

// Close the plot file.
plotFile.close ();

🦋 An Example 2-Dimensional Plot with Error Bars 🦋

The following 2-Dimensional plot with error bars in the x and y directions

[image]

was created using the following code from gnuplot-example.cc:

using namespace std;

string fileNameWithNoExtension = "plot-2d-with-error-bars";
string graphicsFileName        = fileNameWithNoExtension + ".png";
string plotFileName            = fileNameWithNoExtension + ".plt";
string plotTitle               = "2-D Plot With Error Bars";
string dataTitle               = "2-D Data With Error Bars";

// Instantiate the plot and set its title.
Gnuplot plot (graphicsFileName);
plot.SetTitle (plotTitle);

// Make the graphics file, which the plot file will create when it
// is used with Gnuplot, be a PNG file.
plot.SetTerminal ("png");

// Set the labels for each axis.
plot.SetLegend ("X Values", "Y Values");

// Set the range for the x axis.
plot.AppendExtra ("set xrange [-6:+6]");

// Instantiate the dataset, set its title, and make the points be
// plotted with no connecting lines.
Gnuplot2dDataset dataset;
dataset.SetTitle (dataTitle);
dataset.SetStyle (Gnuplot2dDataset::POINTS);

// Make the dataset have error bars in both the x and y directions.
dataset.SetErrorBars (Gnuplot2dDataset::XY);

double x;
double xErrorDelta;
double y;
double yErrorDelta;

// Create the 2-D dataset.
for (x = -5.0; x <= +5.0; x += 1.0)
  {
    // Calculate the 2-D curve
    //
    //            2
    //     y  =  x   .
    //
    y = x * x;

    // Make the uncertainty in the x direction be constant and make
    // the uncertainty in the y direction be a constant fraction of
    // y's value.
    xErrorDelta = 0.25;
    yErrorDelta = 0.1 * y;

    // Add this point with uncertainties in both the x and y
    // direction.
    dataset.Add (x, y, xErrorDelta, yErrorDelta);
  }

// Add the dataset to the plot.
plot.AddDataset (dataset);

// Open the plot file.
ofstream plotFile (plotFileName.c_str());

// Write the plot file.
plot.GenerateOutput (plotFile);

// Close the plot file.
plotFile.close ();

🌈 An Example 3-Dimensional Plot 🌈

The following 3-Dimensional plot

[image]

was created using the following code from gnuplot-example.cc:

using namespace std;

string fileNameWithNoExtension = "plot-3d";
string graphicsFileName        = fileNameWithNoExtension + ".png";
string plotFileName            = fileNameWithNoExtension + ".plt";
string plotTitle               = "3-D Plot";
string dataTitle               = "3-D Data";

// Instantiate the plot and set its title.
Gnuplot plot (graphicsFileName);
plot.SetTitle (plotTitle);

// Make the graphics file, which the plot file will create when it
// is used with Gnuplot, be a PNG file.
plot.SetTerminal ("png");

// Rotate the plot 30 degrees around the x axis and then rotate the
// plot 120 degrees around the new z axis.
plot.AppendExtra ("set view 30, 120, 1.0, 1.0");

// Make the zero for the z-axis be in the x-axis and y-axis plane.
plot.AppendExtra ("set ticslevel 0");

// Set the labels for each axis.
plot.AppendExtra ("set xlabel 'X Values'");
plot.AppendExtra ("set ylabel 'Y Values'");
plot.AppendExtra ("set zlabel 'Z Values'");

// Set the ranges for the x and y axis.
plot.AppendExtra ("set xrange [-5:+5]");
plot.AppendExtra ("set yrange [-5:+5]");

// Instantiate the dataset, set its title, and make the points be
// connected by lines.
Gnuplot3dDataset dataset;
dataset.SetTitle (dataTitle);
dataset.SetStyle ("with lines");

double x;
double y;
double z;

// Create the 3-D dataset.
for (x = -5.0; x <= +5.0; x += 1.0)
  {
  for (y = -5.0; y <= +5.0; y += 1.0)
      {
        // Calculate the 3-D surface
        //
        //            2      2
        //     z  =  x   *  y   .
        //
        z = x * x * y * y;

        // Add this point.
        dataset.Add (x, y, z);
      }

  // The blank line is necessary at the end of each x value's data
  // points for the 3-D surface grid to work.
  dataset.AddEmptyLine ();
  }

// Add the dataset to the plot.
plot.AddDataset (dataset);

// Open the plot file.
ofstream plotFile (plotFileName.c_str());

// Write the plot file.
plot.GenerateOutput (plotFile);

// Close the plot file.
plotFile.close ();

🐍 USING PYTHON TO RUN NS-3 🐍

Python bindings let the C++ code in ns-3 be called from Python. no cap 💅

This chapter shows you how to create a Python script that can run ns-3, and also the whole process of creating Python bindings for a C++ ns-3 module. ✨


🌸 Introduction 🌸

Python bindings provide support for importing ns-3 model libraries as Python modules. Coverage of most of the ns-3 C++ API is provided. The whole intent has been to let the programmer write complete simulation scripts in Python, and to allow integration of ns-3 with other Python tools and workflows. The intent is not to provide a different language choice to author new ns-3 models implemented in Python, okurrr.

Python bindings for ns-3 use a tool called PyBindGen (https://github.com/gjcarneiro/pybindgen) to create Python modules from the C++ libraries built by Waf. The Python bindings that PyBindGen uses are maintained in a bindings directory in each module, and must be maintained to match the C++ API of that ns-3 module. If the C++ API changes, the Python bindings file must either be modified by hand accordingly, or the bindings must be regenerated by an automated scanning process. 😭

If a user is not interested in Python, he or she may disable the use of Python bindings at Waf configure time. In this case, changes to the C++ API of a provided module will not cause the module to fail to compile. blessed 🙈

The process for automatically generating Python bindings relies on a toolchain involving a development installation of the Clang compiler, a program called CastXML (https://github.com/CastXML/CastXML), and a program called pygccxml (https://github.com/gccxml/pygccxml). The toolchain can be installed using the ns-3 bake build tool. 👑


💖 An Example Python Script that Runs ns-3 💖

Here is some example code that is written in Python and that runs ns-3, which is written in C++. This Python example can be found in examples/tutorial/first.py:

import ns.applications
import ns.core
import ns.internet
import ns.network
import ns.point_to_point

ns.core.LogComponentEnable("UdpEchoClientApplication", ns.core.LOG_LEVEL_INFO)
ns.core.LogComponentEnable("UdpEchoServerApplication", ns.core.LOG_LEVEL_INFO)

nodes = ns.network.NodeContainer()
nodes.Create(2)
pointToPoint = ns.point_to_point.PointToPointHelper()
pointToPoint.SetDeviceAttribute("DataRate", ns.core.StringValue("5Mbps"))
pointToPoint.SetChannelAttribute("Delay", ns.core.StringValue("2ms"))

devices = pointToPoint.Install(nodes)

stack = ns.internet.InternetStackHelper()
stack.Install(nodes)

address = ns.internet.Ipv4AddressHelper()
address.SetBase(ns.network.Ipv4Address("10.1.1.0"), ns.network.Ipv4Mask("255.255.255.0"))

interfaces = address.Assign (devices);

echoServer = ns.applications.UdpEchoServerHelper(9)

serverApps = echoServer.Install(nodes.Get(1))
serverApps.Start(ns.core.Seconds(1.0))
serverApps.Stop(ns.core.Seconds(10.0))

echoClient = ns.applications.UdpEchoClientHelper(interfaces.GetAddress(1), 9)
echoClient.SetAttribute("MaxPackets", ns.core.UintegerValue(1))
echoClient.SetAttribute("Interval", ns.core.TimeValue(ns.core.Seconds (1.0)))
echoClient.SetAttribute("PacketSize", ns.core.UintegerValue(1024))

clientApps = echoClient.Install(nodes.Get(0))
clientApps.Start(ns.core.Seconds(2.0))
clientApps.Stop(ns.core.Seconds(10.0))

ns.core.Simulator.Run()
ns.core.Simulator.Destroy()

🌈 Running Python Scripts 🌈

okay so waf</p> is out here doing double duty 💅 it's got some options that automatically update the python path so it can actually find the ns3 module. To run example programs there are two ways to let waf handle all that for you.

One is to run a waf shell, like this:

$ ./waf shell
$ python examples/wireless/mixed-wireless.py

and the other is to use the –pyrun option to waf:

$ ./waf --pyrun examples/wireless/mixed-wireless.py

As of ns-3.30, a –pyrun-no-build option was added that lets you run a program without invoking a project rebuild. This one is lowkey iconic for improving execution time when you're running the same program repeatedly but with different arguments — like, say, from scripts 👀 It can be used in place of –pyrun, like so:

$ ./waf --pyrun-no-build examples/wireless/mixed-wireless.py

To run a python script under the C debugger:

$ ./waf shell
$ gdb --args python examples/wireless/mixed-wireless.py

And to run your own Python script that calls ns-3, living at /path/to/your/example/my-script.py, do this:

$ ./waf shell
$ python /path/to/your/example/my-script.py

🫶 Caveats 🫶

Python bindings for ns-3 are a work in progress fr fr, and the developers know about some limitations. Some of these limitations (not all, bestie, not all) are listed right here. 💀


😭 Incomplete Coverage 😭

First of all, keep in mind that not 100% of the API is supported in Python. Some of the reasons are:

  1. some of the APIs involve pointers, which require knowledge of what kind of memory passing semantics (who owns what memory). Such knowledge is not part of the function signatures, and is either documented or sometimes not even documented. Annotations are needed to bind those functions;
  2. Sometimes a unusual fundamental data type or C++ construct is used which is not yet supported by PyBindGen;
  3. CastXML does not report template based classes unless they are instantiated.

Most of the missing APIs can be wrapped, given enough time, patience, and expertise, and will likely be wrapped if bug reports are submitted. But — and hear me on this — don't file a bug report saying "bindings are incomplete", because we do not have manpower to complete 100% of the bindings. That's not a vibe. 🙈


🎀 Conversion Constructors 🎀

Conversion constructors are not fully supported yet by PyBindGen, and they always act as explicit constructors when translating an API into Python. For example, in C++ you can do this:

Ipv4AddressHelper ipAddrs;
ipAddrs.SetBase ("192.168.0.0", "255.255.255.0");
ipAddrs.Assign (backboneDevices);

In Python, for the time being you have to do:

ipAddrs = ns.internet.Ipv4AddressHelper()
ipAddrs.SetBase(ns.network.Ipv4Address("192.168.0.0"), ns.network.Ipv4Mask("255.255.255.0"))
ipAddrs.Assign(backboneDevices)

💅 CommandLine 💅

CommandLine::AddValue() works differently in Python than it does in ns-3. In Python, the first parameter is a string that represents the command-line option name. When the option is set, an attribute with the same name as the option name is set on the CommandLine() object. Example:

NUM_NODES_SIDE_DEFAULT = 3

cmd = ns3.CommandLine()

cmd.NumNodesSide = None
cmd.AddValue("NumNodesSide", "Grid side number of nodes (total number of nodes will be this number squared)")

cmd.Parse(argv)

[...]

if cmd.NumNodesSide is None:
    num_nodes_side = NUM_NODES_SIDE_DEFAULT
else:
    num_nodes_side = int(cmd.NumNodesSide)

🦋 Tracing 🦋

Callback based tracing is not yet properly supported for Python, as new ns-3 API needs to be provided for this to be supported.

Pcap file writing is supported via the normal API. ✅

ASCII tracing is supported since ns-3.4 via the normal C++ API translated to Python. However, ASCII tracing requires the creation of an ostream object to pass into the ASCII tracing methods. In Python, the C++ std::ofstream has been minimally wrapped to allow this. For example:

ascii = ns3.ofstream("wifi-ap.tr") # create the file
ns3.YansWifiPhyHelper.EnableAsciiAll(ascii)
ns3.Simulator.Run()
ns3.Simulator.Destroy()
ascii.close() # close the file
There is one caveat: you must not allow the file object to be garbage collected while ns-3 is still using it. That means that the 'ascii' variable above must not be allowed to go out of scope or else the program will crash. 💀 no cap.

🌸 Working with Python Bindings 🌸

Python bindings are built on a module-by-module basis, and can be found in each module's bindings directory.


✨ Overview ✨

The python bindings are generated into an ns namespace. Examples:

from ns.network import Node
n1 = Node()

or

import ns.network
n1 = ns.network.Node()

The best way to explore the bindings is to look at the various example programs provided in ns-3; some C++ examples have a corresponding Python example. There is no structured documentation for the Python bindings like there is Doxygen for the C++ API, but the Doxygen can be consulted to understand how the C++ API works. iykyk 📚


🧚 Python Bindings Workflow 🧚

The process by which Python bindings are handled is the following:

  1. Periodically a developer uses a CastXML (‐ https://github.com/CastXML/CastXML) based API scanning script, which saves the scanned API definition as bindings/python/ns3_module_*.py files or as Python files in each modules' bindings directory. These files are kept under version control in the main ns-3 repository;
  2. Other developers clone the repository and use the already scanned API definitions;
  3. When configuring ns-3, pybindgen will be automatically downloaded if not already installed. Released ns-3 tarballs will ship a copy of pybindgen.

If something goes wrong with compiling Python bindings and you just want to ignore them and move on with C++, you can disable Python with:

$ ./waf configure --disable-python ...

To add support for modular bindings to an existing or new ns-3 module, simply add the following line to its wscript build() function:

bld.ns3_python_bindings()

One must also provide the bindings files (usually by running the scanning framework).


👑 Regenerating the Python bindings 👑

ns-3 will fail to successfully compile the Python bindings if the C++ headers are changed and no longer align with the stored Python bindings. In this case, the developer has two main choices: 1) disable Python as described above, or 2) update the bindings to align with the new C++ API.


⭐ Process Overview ⭐

ns-3 has an automated process to regenerate Python bindings from the C++ header files. The process is only supported for Linux at the moment (ns-3.33) because the project has not found a contributor yet to test and document the capability on macOS. In short, the process currently requires the following steps on a Linux machine.

  1. Prepare the system for scanning by installing the prerequisites, including a development version of clang, the CastXML package, pygccxml, and pybindgen.
  2. Perform a scan of the module of interest or all modules

💜 Installing a clang development environment 💜

Make sure you have a development version of the clang compiler installed on your system. This can take a long time to build from source. Linux distributions provide binary library packages such as libclang-dev (Ubuntu) or clang-devel (Fedora).


🍓 Installing other prerequisites 🍓

cxxfilt is a new requirement, typically installed using pip or pip3; e.g.

pip3 install --user cxxfilt

See also the wiki for installation notes for your system.


🍞 Set up a bake build environment 🍞

Try the following commands:

$ cd bake
$ export PATH=`pwd`/build/bin:$PATH
$ export LD_LIBRARY_PATH=`pwd`/build/lib${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}
$ export PYTHONPATH=`pwd`/build/lib${PYTHONPATH:+:$PYTHONPATH}
$ mkdir -p build/lib

🌟 Configure 🌟

Perform a configuration at the bake level:

$ ./bake.py configure -e ns-3-dev -e pygccxml

The output of ./bake.py show should show something like this:

$ ./bake.py show

Should say (note: some are OK to be 'Missing' for Python bindings scanning):

-- System Dependencies --
 > clang-dev - OK
 > cmake - OK
 > cxxfilt - OK
 > g++ - OK
 > gi-cairo - OK or Missing
 > gir-bindings - OK or Missing
 > llvm-dev - OK
 > pygobject - OK or Missing
 > pygraphviz - OK or Missing
 > python3-dev - OK
 > python3-setuptools - OK
 > qt - OK or Missing
 > setuptools - OK

Note that it is not harmful for Python bindings if some of the items above report as Missing. For Python bindings, the important prerequisites are clang-dev, cmake, cxxfilt, llvm-dev, python3-dev, and python3-setuptools. In the following process, the following programs and libraries will be locally installed: castxml, pybindgen, pygccxml, and ns-3.

Note also that the ns-3-allinone target for bake will also include the pygccxml and ns-3-dev targets (among other libraries) and can be used instead, e.g.:

$ ./bake.py configure -e ns-3-allinone

🌈 Download 🌈

Issue the following download command. Your output may vary depending on what is present or missing on your system.

$ ./bake.py download
 >> Searching for system dependency llvm-dev - OK
 >> Searching for system dependency clang-dev - OK
 >> Searching for system dependency qt - Problem
 > Problem: Optional dependency, module "qt" not available
   This may reduce the  functionality of the final build.
   However, bake will continue since "qt" is not an essential dependency.
   For more information call bake with -v or -vvv, for full verbose mode.

 >> Searching for system dependency g++ - OK
 >> Searching for system dependency cxxfilt - OK
 >> Searching for system dependency setuptools - OK
 >> Searching for system dependency python3-setuptools - OK
 >> Searching for system dependency gi-cairo - Problem
 > Problem: Optional dependency, module "gi-cairo" not available
   This may reduce the  functionality of the final build.
   However, bake will continue since "gi-cairo" is not an essential dependency.
   For more information call bake with -v or -vvv, for full verbose mode.

 >> Searching for system dependency gir-bindings - Problem
 > Problem: Optional dependency, module "gir-bindings" not available
   This may reduce the  functionality of the final build.
   However, bake will continue since "gir-bindings" is not an essential dependency.
   For more information call bake with -v or -vvv, for full verbose mode.

 >> Searching for system dependency pygobject - Problem
 > Problem: Optional dependency, module "pygobject" not available
   This may reduce the  functionality of the final build.
   However, bake will continue since "pygobject" is not an essential dependency.
   For more information call bake with -v or -vvv, for full verbose mode.

 >> Searching for system dependency pygraphviz - Problem
 > Problem: Optional dependency, module "pygraphviz" not available
   This may reduce the  functionality of the final build.
   However, bake will continue since "pygraphviz" is not an essential dependency.
   For more information call bake with -v or -vvv, for full verbose mode.

 >> Searching for system dependency python3-dev - OK
 >> Searching for system dependency cmake - OK
 >> Downloading castxml - OK
 >> Downloading netanim - OK
 >> Downloading pybindgen - OK
 >> Downloading pygccxml - OK
 >> Downloading ns-3-dev - OK

Build 🔨

ok so next up, bestie, you literally just run this:

$ ./bake.py build

and you should get a lil build report for each package, kinda like this 💖:

>> Building castxml - OK
>> Building netanim - OK
>> Building pybindgen - OK
>> Building pygccxml - OK
>> Building ns-3-dev - OK

but bestie — if something goes sideways with the bindings compilation (or with the C++ code 👀), it does not say OK, it reports a failure instead:

>> Building ns-3-dev - Problem
> Error:  Critical dependency, module "ns-3-dev" failed
  For more information call Bake with --debug and/or -v, -vvv, for full verbose mode (bake --help)

at this point, the move is to cd into the ns-3-dev directory and just keep working from there, because the API scanning dependencies have already been built and installed successfully into the build directory ✨. you can peek at the output of ./waf configure to see if Python API scanning support is enabled:

Python API Scanning Support   : enabled

and if the support isn't active, or something went wrong during the build process, it might say something like:

Python API Scanning Support   : not enabled (Missing 'pygccxml' Python module)

in that case you (yes you) have to take extra steps to fix it 🫶. for the API scanning support to even be detected, the castxml binary has to be in the shell's PATH, and pygccxml has to be in the PYTHONPATH.

LP64 vs ILP32 bindings 🧬

so Linux (64-bit, which is what basically every modern install is) and MacOS use different data models, and there's a whole explainer here: https://www.ibm.com/support/knowledgecenter/en/SSLTBW_2.3.0/com.ibm.zos.v2r3.cbcpx01/datatypesize64.htm

Linux uses the LP64 model, while MacOS (and 32-bit Linux, fr) use the ILP32 model. you'll notice there are two versions of the bindings files hanging out in every ns-3 module directory — one with an ILP32.py suffix and one with an LP64.py suffix. only one of them gets used on any given platform, periodt. the main difference? it's all about how the 64-bit integer type gets represented — either as a long (LP64) or a long long (ILP32).

the process (only supported on Linux rn, lowkey) generates the LP64 bindings using the toolchain, and then copies those LP64 bindings over to the ILP32 bindings with some type substitutions automated by Waf scripts. slay.

Rescanning a module 🔁

to re-scan a single module:

$ cd source/ns-3-dev
$ ./waf --apiscan=wifi

to re-scan literally all of them:

$ cd source/ns-3-dev
$ ./waf --apiscan=all

Generating bindings on MacOS 🍎

in principle this should work (and it should generate the 32-bit bindings). however, maintainers have not been available to complete this port to date 😭. suggestions on how to enable scanning for MacOS would be very welcome!

Organization of the Modular Python Bindings 🗂️

the src/<module>/bindings directory may contain the following files, some of them optional:

  • callbacks_list.py: this is a scanned file, DO NOT TOUCH. contains a list of Callback<…> template instances found in the scanned headers;
  • modulegen__gcc_LP64.py: this is a scanned file, DO NOT TOUCH. scanned API definitions for the GCC, LP64 architecture (64-bit)
  • modulegen__gcc_ILP32.py: this is a scanned file, DO NOT TOUCH. scanned API definitions for the GCC, ILP32 architecture (32-bit)
  • modulegen_customizations.py: you may optionally add this file in order to customize the pybindgen code generation
  • scan-header.h: you may optionally add this file to customize what header file is scanned for the module. basically this file is scanned instead of ns3/<module>-module.h. typically, the first statement is #include “ns3/<module>-module.h”, plus some other stuff to force template instantiations;
  • module_helpers.cc: you may add additional files, such as this, to be linked to python extension module, but they have to be registered in the wscript. look at src/core/wscript for an example of how to do so;
  • <module>.py: if this file exists, it becomes the “frontend” python module for the ns3 module, and the extension module (.so file) becomes _<module>.so instead of <module>.so. the <module>.py file has to import all symbols from the module _<module> (this is more tricky than it sounds, see src/core/bindings/core.py for an example), and then can add some additional pure-python definitions.

Historical Information 📜

if you're a developer and you need more background info on ns-3's Python bindings, go check out the Python Bindings wiki page 💖. please note, however, that some information on that page is stale. 🙈


🧪 TESTS 🧪

Overview ✨

this chapter is all about the testing and validation of ns-3 software.

this chapter provides:

  • background about terminology and software testing
  • a description of the ns-3 testing framework
  • a guide to model developers or new model contributors for how to write tests

Background 🧠

if you already know the basics of software testing, you can totally skip this chapter, bestie.

writing defect-free software is a difficult proposition. there are so many dimensions to this problem and honestly there's a lot of confusion about what different terms even mean in different contexts 😵‍💫. so the project found it worthwhile to spend a lil time reviewing the subject and defining some terms.

software testing may be loosely defined as the process of executing a program with the intent of finding errors. and the moment you enter a discussion about software testing, it becomes immediately apparent that there are sooo many distinct mind-sets with which one can approach the subject.

for example, you could break the process into broad functional categories like “correctness testing,” “performance testing,” “robustness testing” and “security testing.” another way to look at the problem is by life-cycle: “requirements testing,” “design testing,” “acceptance testing,” and “maintenance testing.” yet another view is by the scope of the tested system. in this case one may speak of “unit testing,” “component testing,” “integration testing,” and “system testing.” these terms are also not standardized in any way, so “maintenance testing” and “regression testing” may be heard interchangeably. and on top of that, these terms are often misused 💀.

there are also a number of different philosophical approaches to software testing. for example, some organizations advocate writing test programs before actually implementing the desired software, yielding “test-driven development.” some organizations advocate testing from a customer perspective as soon as possible, following a parallel with the agile development process: “test early and test often.” this is sometimes called “agile testing.” it seems like there's at least one approach to testing for every development methodology, fr fr.

the ns-3 project is not in the business of advocating for any one of these processes, but the project as a whole has requirements that help inform the test process.

like all major software products, ns-3 has a number of qualities that must be present for the product to succeed. from a testing perspective, some of these qualities that must be addressed are that ns-3 must be “correct,” “robust,” “performant” and “maintainable.” ideally there should be metrics for each of these dimensions that get checked by the tests, to identify when the product fails to meet its expectations / requirements. 👑

Correctness ✅

the essential purpose of testing is to determine that a piece of software behaves “correctly.” for ns-3 this means that if we simulate something, the simulation should faithfully represent some physical entity or process to a specified accuracy and precision.

it turns out that there are two perspectives from which one can view correctness 👀. verifying that a particular model is implemented according to its specification is generically called verification. the process of deciding that the model is correct for its intended use is generically called validation.

🧪 Validation and Verification 🧪

Okay bestie, real talk time. A computer model is a mathematical or logical representation of something. Could be a vehicle, could be an elephant (yes fr, go watch David Harel's talk about modeling an elephant at SIMUTools 2009, iconic behavior), could be a networking card. Models can also represent processes — global warming, freeway traffic flow, or the spec for a networking protocol. And here's the thing: models can be completely faithful representations of a logical process specification, but they can never completely simulate a physical object or process. Never ever. So in most cases a bunch of simplifications get made, purely so the simulation is computationally tractable. It's giving necessary compromise 💅

Every model has a target system it's trying to simulate. Step one of making a simulation model is identifying that target system, plus how much detail and accuracy you actually want the simulation to reproduce. If your target is a logical process, you might literally say it's "TCP as defined by RFC 793" — and in that case, yeah, you probably want a model that completely and faithfully reproduces RFC 793. But if it's a physical process? Not possible, periodt. Say you wanna simulate a wireless networking card — you might decide you need "an accurate MAC-level implementation of the 802.11 specification and […] a not-so-slow PHY-level model of the 802.11a specification."

Once that's settled, you develop an abstract model of the target system. This is basically the art of managing tradeoffs between complexity, resource requirements, and accuracy. The literature calls this process model qualification. For a TCP protocol, this gives you a design for a collection of objects, interactions and behaviors that fully implement RFC 793 in ns-3. For the wireless card, this gives you a bunch of tradeoffs that let the physical layer be simulated, plus the design of a network device and channel for ns-3, along with the desired objects, interactions and behaviors.

Then that abstract model gets developed into an actual ns-3 model — the abstract model, implemented as a computer program. Getting the implementation to agree with the abstract model is called model verification in the literature.

So far the whole process is open loop. What's left is deciding that a given ns-3 model has some connection to reality — that the model is an accurate representation of a real system, whether that's a logical process or a physical entity.

Because if you're gonna use a simulation model to predict how some real system behaves, there has to be some reason to believe your results. Like, can you trust that an inference made from the model translates into a correct prediction for the real system? The process of getting the ns-3 model behavior to agree with the desired target system behavior — as defined by the model qualification process — is called model validation in the literature. For a TCP implementation, you might compare your ns-3 TCP model's behavior to some reference implementation to validate it. For a wireless physical layer simulation, you might compare your model's behavior to that of real hardware in a controlled setting,

The ns-3 testing environment provides tools to allow for both model validation and testing, and encourages the publication of validation results. 🫶


💪 Robustness 💪

Robustness is the quality of being able to withstand stresses — changes in environments, inputs, calculations, whatever. A system or design is "robust" if it can deal with those changes with minimal loss of functionality.

This kind of testing usually has a particular focus. For example, the system as a whole can be run on many different system configurations to show it performs correctly across a large number of environments.

The system can also be stressed by operating close to or beyond capacity — generating or simulating resource exhaustion of various kinds. This genre of testing is called "stress testing."

The system and its components may be exposed to so-called "clean tests" that demonstrate a positive result — meaning the system operates correctly in response to a large variation of expected configurations.

They may also be exposed to "dirty tests" which feed in inputs outside the expected range. Example: if a module expects a zero-terminated string representation of an integer, a dirty test might hand it an unterminated string of random characters, just to verify the system doesn't crash from that unexpected input. 😭

Unfortunately, detecting such "dirty" input and taking preventive measures to make sure the system doesn't fail catastrophically can require a huge amount of development overhead. So to reduce development time, a decision was taken early on in the project to minimize the amount of parameter validation and error handling in the ns-3 codebase. For that reason, we don't spend much time on dirty testing — it would just uncover the results of the design decision we know we took. No cap.

We do want to demonstrate that ns-3 software works across some set of conditions, though. So we borrow a couple of definitions to narrow this down:

  • The domain of applicability is a set of prescribed conditions for which the model has been tested, compared against reality to the extent possible, and judged suitable for use.
  • The range of accuracy is an agreement between the computerized model and reality within a domain of applicability.

The ns-3 testing environment provides tools to allow for setting up and running test environments over multiple systems (buildbot) and provides classes to encourage clean tests to verify the operation of the system over the expected "domain of applicability" and "range of accuracy."


⚡ Performant ⚡

Okay so, "performant" isn't a real English word. It is, however, a very concise neologism that gets used a LOT to describe what we want ns-3 to be: powerful and fast enough to get the job done. Slay 💅

This is really about the broad subject of software performance testing. One of the key things done is comparing two systems to find which performs better (cf benchmarks). This gets used to demonstrate that, for example, ns-3 can perform a basic kind of simulation at least as fast as a competing tool — or it can be used to identify parts of the system that perform badly. 🙈

In the ns-3 test framework, we provide support for timing various kinds of tests.


🛠️ Maintainability 🛠️

A software product must be maintainable. Again, super broad statement, but a testing framework can help with the task. Once a model has been developed, validated and verified, we can repeatedly execute the suite of tests for the entire system to make sure it stays valid and verified over its lifetime.

When a feature stops functioning as intended after some kind of change to the system gets integrated, it's called generically a regression. Originally the term referred to a change that caused a previously fixed bug to reappear, but it's evolved to describe any kind of change that breaks existing functionality. There are many kinds of regressions that may occur in practice:

  • A local regression is one where a change affects the changed component directly. Example: if a component is modified to allocate and free memory but stale pointers get used, the component itself fails.
  • A remote regression is one where a change to one component breaks functionality in another component. This reflects a violation of an implied but possibly unrecognized contract between components.
  • An unmasked regression is one that creates a situation where a previously existing bug that had no affect is suddenly exposed in the system. This may be as simple as exercising a code path for the first time.
  • A performance regression is one that causes the performance requirements of the system to be violated. Example: doing some work in a low level function that may be repeated large numbers of times can suddenly render the system unusable from certain perspectives.

The ns-3 testing framework provides tools for automating the process used to validate and verify the code in nightly test suites, to help quickly identify possible regressions. 🦋


🧪 Testing framework 🧪

ns-3 consists of a simulation core engine, a set of models, example programs, and tests. Over time, new contributors contribute models, tests, and examples. A Python test program test.py serves as the test execution manager; test.py can run test code and examples to look for regressions, can output the results into a number of forms, and can manage code coverage analysis tools. On top of this, we layer buildslaves — automated build robots that perform robustness testing by running the test framework on different systems and with different configuration options.

🤖 Buildslaves 🤖

At the highest level of ns-3 testing are the buildslaves (build robots). If you're unfamiliar with this system, look at https://ns-buildmaster.ee.washington.edu:8010/. This is an open-source automated system that allows ns-3 to be rebuilt and tested daily. By running the buildbots on a number of different systems, we can ensure ns-3 builds and executes properly on all of its supported systems. 👑

Users (and developers) typically will not interact with the buildslave system other than to read its messages regarding test results. If a failure is detected in one of the automated build and test jobs, the buildbot will send an email to the ns-commits mailing list. This email will look something like

[Ns-commits] Build failed in Jenkins: daily-ubuntu-without-valgrind » Ubuntu-64-15.04 #926

...
281 of 285 tests passed (281 passed, 3 skipped, 1 failed, 0 crashed, 0 valgrind errors)
List of SKIPped tests:
  ns3-tcp-cwnd
  ns3-tcp-interoperability
  nsc-tcp-loss
List of FAILed tests:
  random-variable-stream-generators
+ exit 1
Build step 'Execute shell' marked build as failure

In the full details URL shown in the email, one can find links to the detailed test output.

The buildslave system will do its job quietly if there are no errors, and the system will undergo build and test cycles every day to verify that all is well. 🍓

🐍 Test.py 🐍

The buildbots use a Python program, test.py, that is responsible for running all of the tests and collecting the resulting reports into a human-readable form. This program is also available for use by users and developers as well.

okay bestie, real talk: test.py is SO flexible it basically lets you choose <em>how many</em> tests you run, <em>what kind</em> of tests you run, and <em>how much</em> tea it spills about the results. pick your vibe, pick your output, go off. 💅

Before you run test.py at all, make sure ns-3's examples and tests have actually been built, okay? Do this first:

$ ./waf configure --enable-examples --enable-tests
$ ./waf build

By default, test.py runs every single test available and reports back in a super concise little format. Like, no fluff. Just running

$ ./test.py

gives you a bunch of PASS, FAIL, CRASH or SKIP indications, followed by the kind of test that ran and its display name. it's giving report card energy:

Waf: Entering directory `/home/craigdo/repos/ns-3-allinone-test/ns-3-dev/build'
Waf: Leaving directory `/home/craigdo/repos/ns-3-allinone-test/ns-3-dev/build'
'build' finished successfully (0.939s)
FAIL: TestSuite propagation-loss-model
PASS: TestSuite object-name-service
PASS: TestSuite pcap-file-object
PASS: TestSuite ns3-tcp-cwnd
...
PASS: TestSuite ns3-tcp-interoperability
PASS: Example csma-broadcast
PASS: Example csma-multicast

This mode is literally made for two kinds of people: users who just wanna know if their distribution is working correctly, and developers who wanna know if their changes caused any regressions. both valid, both iconic. 🫶


✨ OPTIONS ✨

There's a whole buffet of options to control how test.py behaves. run test.py --help and you should see a command summary like this:

Usage: test.py [options]

Options:
  -h, --help            show this help message and exit
  -b BUILDPATH, --buildpath=BUILDPATH
                        specify the path where ns-3 was built (defaults to the
                        build directory for the current variant)
  -c KIND, --constrain=KIND
                        constrain the test-runner by kind of test
  -e EXAMPLE, --example=EXAMPLE
                        specify a single example to run (no relative path is
                        needed)
  -d, --duration        print the duration of each test suite and example
  -e EXAMPLE, --example=EXAMPLE
                        specify a single example to run (no relative path is
                        needed)
  -u, --update-data     If examples use reference data files, get them to re-
                        generate them
  -f FULLNESS, --fullness=FULLNESS
                        choose the duration of tests to run: QUICK, EXTENSIVE,
                        or TAKES_FOREVER, where EXTENSIVE includes QUICK and
                        TAKES_FOREVER includes QUICK and EXTENSIVE (only QUICK
                        tests are run by default)
  -g, --grind           run the test suites and examples using valgrind
  -k, --kinds           print the kinds of tests available
  -l, --list            print the list of known tests
  -m, --multiple        report multiple failures from test suites and test
                        cases
  -n, --nowaf           do not run waf before starting testing
  -p PYEXAMPLE, --pyexample=PYEXAMPLE
                        specify a single python example to run (with relative
                        path)
  -r, --retain          retain all temporary files (which are normally
                        deleted)
  -s TEST-SUITE, --suite=TEST-SUITE
                        specify a single test suite to run
  -t TEXT-FILE, --text=TEXT-FILE
                        write detailed test results into TEXT-FILE.txt
  -v, --verbose         print progress and informational messages
  -w HTML-FILE, --web=HTML-FILE, --html=HTML-FILE
                        write detailed test results into HTML-FILE.html
  -x XML-FILE, --xml=XML-FILE
                        write detailed test results into XML-FILE.xml
ngl bestie, -e EXAMPLE, --example=EXAMPLE shows up twice in that help output. not a typo on my end, that's literally what it prints. i don't make the rules. 🙈

🌸 output styles 🌸

If you specify an optional output style, you can generate detailed descriptions of the tests and their status. the available styles are text and HTML. the buildbots pick the HTML option to generate HTML test reports for the nightly builds, like this:

$ ./test.py --html=nightly.html

In that case, an HTML file named ''nightly.html'' gets created with a pretty summary of the testing that was done. adorable. and if you're a details girlie, there's a ''human readable'' format too:

$ ./test.py --text=results.txt

In the example above (iykyk), the test suite checking the ns-3 wireless device propagation loss models failed. and by default, test.py gives you <em>no further information</em>. literally nothing. so mysterious. 😭


💀 digging into a failure 💀

To actually explore that failure, test.py lets you specify a single test suite. running

$ ./test.py --suite=propagation-loss-model

or, equivalently,

$ ./test.py -s propagation-loss-model

results in that single test suite being run:

FAIL: TestSuite propagation-loss-model

To find detailed info about the failure, you have to specify the kind of output you want. most people will probably want a text file:

$ ./test.py --suite=propagation-loss-model --text=results.txt

This runs that single test suite and writes the test status to the file ''results.txt''. you should find something similar to this in there:

FAIL: Test Suite ''propagation-loss-model'' (real 0.02 user 0.01 system 0.00)
PASS: Test Case "Check ... Friis ... model ..." (real 0.01 user 0.00 system 0.00)
FAIL: Test Case "Check ... Log Distance ... model" (real 0.01 user 0.01 system 0.00)
  Details:
    Message:   Got unexpected SNR value
    Condition: [long description of what actually failed]
    Actual:    176.395
    Limit:     176.407 +- 0.0005
    File:      ../src/test/ns3wifi/propagation-loss-models-test-suite.cc
    Line:      360

Notice that the Test Suite is composed of two Test Cases. the first test case checked the Friis propagation loss model and passed. the second test case failed while checking the Log Distance propagation model. in this case, an SNR of 176.395 was found, and the test expected a value of 176.407 correct to three decimal places. the file that implemented the failing test is listed, plus the line of code that triggered the failure. receipts! 🧾

If you desire, you could just as easily have written an HTML file using the --html option as described above.


👑 how people actually use this 👑

Typically, a user will run all tests at least once after downloading ns-3, just to make sure their environment has been built correctly and is generating correct results according to the test suites. developers, though, typically run the test suites before <em>and</em> after making a change, so they know they haven't introduced a regression with their changes.

In that case, developers may not want to run all the tests, just a subset. for example, maybe they only wanna run the unit tests periodically while making changes to a repository. in that case, test.py can be told to constrain the types of tests being run to a particular class of tests. this command runs only the unit tests:

$ ./test.py --constrain=unit

To see a quick list of the legal kinds of constraints, just ask for them to be listed. this command

$ ./test.py --kinds

displays the following list:

Waf: Entering directory `/home/craigdo/repos/ns-3-allinone-test/ns-3-dev/build'
Waf: Leaving directory `/home/craigdo/repos/ns-3-allinone-test/ns-3-dev/build'
'build' finished successfully (0.939s)Waf: Entering directory `/home/craigdo/repos/ns-3-allinone-test/ns-3-dev/build'
core:        Run all TestSuite-based tests (exclude examples)
example:     Examples (to see if example programs run successfully)
performance: Performance Tests (check to see if the system is as fast as expected)
system:      System Tests (spans modules to check integration of modules)
unit:        Unit Tests (within modules to check basic functionality)

Any of these kinds of test can be provided as a constraint using the --constraint option. (yes, the option is spelled --constrain up above and --constraint right here. that's just how the page do. 🙈)


🌈 listing everything 🌈

To see a quick list of <em>all</em> the test suites available, ask for them to be listed. this command,

$ ./test.py --list

displays a list of the test suites, similar to:

Waf: Entering directory `/home/craigdo/repos/ns-3-allinone-test/ns-3-dev/build'
Waf: Leaving directory `/home/craigdo/repos/ns-3-allinone-test/ns-3-dev/build'
'build' finished successfully (0.939s)

Test Type    Test Name
---------    ---------
performance  many-uniform-random-variables-one-get-value-call
performance  one-uniform-random-variable-many-get-value-calls
performance  type-id-perf
system       buildings-pathloss-test
system       buildings-shadowing-test
system       devices-mesh-dot11s-regression
system       devices-mesh-flame-regression
system       epc-gtpu
...
unit         wimax-phy-layer
unit         wimax-service-flow
unit         wimax-ss-mac-layer
unit         wimax-tlv
example      adhoc-aloha-ideal-phy
example      adhoc-aloha-ideal-phy-matrix-propagation-loss-model
example      adhoc-aloha-ideal-phy-with-microwave-oven
example      aodv
...

Any of these listed suites can be selected to be run by itself using the --suite option as shown above. pick your fighter. 💅

To run multiple test suites at once, you can use a Unix filename pattern matching style, e.g.

$ ../test.py -s 'ipv6*'

Note the use of quotes, bestie — they matter. the result is similar to:

PASS: TestSuite ipv6-protocol
PASS: TestSuite ipv6-packet-info-tag
PASS: TestSuite ipv6-list-routing
PASS: TestSuite ipv6-extension-header
PASS: TestSuite ipv6-address-generator
PASS: TestSuite ipv6-raw
PASS: TestSuite ipv6-dual-stack
PASS: TestSuite ipv6-fragmentation
PASS: TestSuite ipv6-address-helper
PASS: TestSuite ipv6-address
PASS: TestSuite ipv6-forwarding
PASS: TestSuite ipv6-ripng

look at all those passes. we ate. ✨


🎀 running one C++ example 🎀

Similarly to test suites, you can run a single C++ example program using the --example option. quick notes, because these trip people up:

  • the relative path for the example does <em>not</em> need to be included
  • the executables built for C++ examples <em>do not have extensions</em>
  • and the example must be registered as an example to the test framework. it is not sufficient to just create an example and run it through test.py — it must be added to the relevant examples-to-run.py file, explained below.

Entering

$ ./test.py --example=udp-echo

results in that single example being run:

PASS: Example examples/udp/udp-echo

You can specify the directory where ns-3 was built using the --buildpath option as follows.

$ ./test.py --buildpath=/home/craigdo/repos/ns-3-allinone-test/ns-3-dev/build/debug --example=wifi-simple-adhoc

🐍 One Python Example At A Time

okay so if you just want to run a single Python example program, bestie, there's the --pyexample option for exactly that. Two things tho, don't forget them: the relative path to the example has to be included, and Python examples do need their extensions (yes, all of them, no skipping). So if you type

$ ./test.py --pyexample=examples/tutorial/first.py

you'll get that one single example run, and it spits out something like

PASS: Example examples/tutorial/first.py

And here's the cute part: because Python examples aren't built, you do not need to tell it where ns-3 was built. No buildpath needed. Slay. ✨

🗂️ Where All The Trace Files Go

Normally, when example programs run, they write a whole lot of trace file data, and that usually gets saved to the base directory of the distribution (think /home/user/ns-3-dev). But when test.py runs an example, it is genuinely, completely unconcerned with your trace files 💅. It just wants to know: can this example be built, and can it run without error? That's it. That's the whole vibe check.

Since that's the case, the trace files get written into a /tmp/unchecked-traces directory instead. So if you run that example up above, you should be able to find the associated udp-echo.tr and udp-echo-n-1.pcap files chilling in there.

📋 Where The Example List Comes From

The list of available examples? That's defined by the contents of the examples directory in the distribution, no cap. And heads up: if you pick an example to run with the --example option, test.py will not make any attempt to decide whether the example has been configured or not — it'll just try to run it and report back what happened. Like, it's not your mom, it's not checking your homework first. 🙈

🚫 --nowaf — Skip The Build Check

When test.py runs, by default it first makes sure the system has been completely built. You can defeat that with the --nowaf option. So

$ ./test.py --list --nowaf

will give you a list of the currently built test suites, kinda like:

propagation-loss-model
ns3-tcp-cwnd
ns3-tcp-interoperability
pcap-file
object-name-service
random-variable-stream-generators

and notice how there are no Waf build messages in there? Yeah. That's the --nowaf doing its thing. 🎀

🔍 --grind — Valgrind Time

test.py also supports running test suites and examples under valgrind. If you don't know her: Valgrind is a flexible program for debugging and profiling Linux executables. By default it runs a tool called memcheck, which does a whole range of memory-checking functions — detecting accesses to uninitialised memory, misuse of allocated memory (double frees, access after free, etc.), and detecting memory leaks. Iconic, honestly. You select it with the --grind option:

$ ./test.py --grind

💾 --retain — Keep The Temp Files

As it runs, test.py — and the programs it runs indirectly — generate large numbers of temporary files. Usually the content of those files is not interesting, but sometimes (like, for debugging purposes) it's genuinely useful to peek at them. So test.py gives you a --retain option that keeps those temporary files around after the run finishes. They get saved in a directory named testpy-output, under a subdirectory named according to the current Coordinated Universal Time (also known as Greenwich Mean Time).

$ ./test.py --retain

🗣️ --verbose — The Yapper Option

And finally, test.py has a --verbose option that prints large amounts of info about its progress. tbh it's not expected to be terribly useful unless something's gone wrong — but when it has gone wrong, this is how you get access to the standard output and standard error reported by the running test suites and examples. Select it like:

$ ./test.py --verbose

🎛️ Mix & Match

All of these options can be mixed and matched, bestie. For example, to run all of the ns-3 core test suites under valgrind, in verbose mode, while generating an HTML output file, you'd do:

$ ./test.py --verbose --grind --constrain=core --html=results.html

🧬 TestTaxonomy

So like we said up above, tests are grouped into a number of broadly defined classifications, so you can selectively run the ones that address the different kinds of testing that need doing. They are:

  • Build Verification Tests
  • Unit Tests
  • System Tests
  • Examples
  • Performance Tests

And moreover, each test is further classified by how much time it's expected to take to run. Those categories are:

  • QUICK
  • EXTENSIVE
  • TAKES_FOREVER
Heads up: specifying EXTENSIVE will also run tests in the QUICK category. And specifying TAKES_FOREVER will run tests in the EXTENSIVE and QUICK categories. By default, only QUICK tests are ran. ⏳

As a rule of thumb: tests that must run to ensure ns-3 coherence should be QUICK (like, a few seconds). Tests that could be skipped but are nice to do can be EXTENSIVE — those typically need minutes. And TAKES_FOREVER is left for tests that take a really long time, in the order of several minutes. The main classification goal is to be able to run the buildbots in a reasonable time, and still be able to perform more extensive tests when needed. Balance, queen. 👑

🔬 Unit Tests

Unit tests are the more involved ones — they go into detail to make sure a piece of code works as advertised, all on its own. And honestly? There's really no reason for this kind of test to be built into an ns-3 module. Fun fact: the unit tests for the object name service are about the same size as the object name service code itself. 😭

Unit tests check a single bit of functionality that isn't built into the ns-3 code, but lives in the same directory as the code it tests. They might also check the integration of multiple implementation files in a module, no biggie. The file src/core/test/names-test-suite.cc is an example of this kind of test. And src/network/test/pcap-file-test-suite.cc is another one — that one uses a known good pcap file as a test vector file, and it's stored locally in the src/network directory.

🌐 System Tests

System tests are the ones that involve more than one module in the system. There are lots of these running in the current regression framework, but they're typically overloaded examples. So there's a whole new place for this kind of test in the directory src/test. The file src/test/ns3tcp/ns3-interop-test-suite.cc is an example — it uses NSC TCP to test the ns-3 TCP implementation.

Often there will be test vectors required for this kind of test, and they're stored in the directory where the test lives. For example, ns3tcp-interop-response-vectors.pcap is a file consisting of a number of TCP headers used as the expected responses of the ns-3 TCP under test to a stimulus generated by the NSC TCP — which is being used as a "known good" implementation. Cute. 🎀

🎬 Examples

The examples get tested by the framework to make sure they built and will run. Limited checking happens here — currently the pcap files are just written off into /tmp to be discarded. If the example runs (like, doesn't crash) and the exit status is zero, the example passes the smoke test. That's it. ✅

🏎️ Performance Tests

Performance tests are those that exercise a particular part of the system and determine whether the tests have executed to completion in a reasonable time. Very "how fast can you go" of them.

🏃 Running Tests

Tests are typically run using the high level test.py program. To get a list of the available command-line options, run test.py --help.

The test.py program will run both tests and those examples that have been added to the list to check. So what's the difference? Here's the tea: tests generally check that specific simulation output or events conforms to expected behavior. In contrast, the output of examples is not checked — the test program merely checks the exit status of the example program to make sure it runs without error.

So briefly, to run all tests: first you configure tests during the configuration stage, and also (optionally) examples if examples are to be checked:

$ ./waf --configure --enable-examples --enable-tests

Then you build ns-3, and after it's built, just run test.py. test.py -h will show you a number of configuration options that modify the behavior of test.py.

The program test.py invokes, for C++ tests and examples, a lower-level C++ program called test-runner to actually run the tests. And as discussed below, this test-runner can be a genuinely helpful way to debug tests. 🔥

🐛 Debugging Tests

The debugging of the test programs is best performed by running the low-level test-runner program. The test-runner is the bridge from generic Python code to ns-3 code. It's written in C++ and uses the automatic test discovery process in the ns-3 code to find and allow execution of all of the various tests.

So the main reason test.py isn't suitable for debugging is that it is not allowed for logging to be turned on using the NS_LOG environmental variable when test.py runs. This limitation does not apply to the test-runner executable. Hence, if you want to see logging output from your tests, you have to run them using the test-runner directly.

In order to execute the test-runner, you run it like any other ns-3 executable — using waf. To get a list of available options, you can type:

$ ./waf --run "test-runner --help"

You should see something like the following

Usage: /home/craigdo/repos/ns-3-allinone-test/ns-3-dev/build/utils/ns3-dev-test-runner-debug [OPTIONS]

✨ Options ✨

ok so here's the whole menu of flags the test-runner hands you, bestie — read it and weep 💅

  • --help — prints these options
  • --print-test-name-list — prints the list of names of every test that's available
  • --list — an alias for --print-test-name-list
  • --print-test-types — prints the type of each test, right alongside its name
  • --print-test-type-list — prints the list of types of tests that exist
  • --print-temp-dir — prints the name of the temporary directory before it runs the tests
  • --test-type=TYPE — only processes tests of type TYPE
  • --test-name=NAME — only processes the test whose name matches NAME
  • --suite=NAME — an alias (it's only here for compatibility reasons) for --test-name=NAME
  • --assert-on-failure — when a test fails, crash immediately (useful when running under a debugger)
  • --stop-on-failure — when a test fails, stop immediately
  • --fullness=FULLNESS — picks how long the tests you run can be: QUICK, EXTENSIVE, or TAKES_FOREVER, where EXTENSIVE includes QUICK and TAKES_FOREVER includes both QUICK and EXTENSIVE (and yeah, only QUICK tests run by default)
  • --verbose — prints the details of test execution
  • --xml — formats the test run output as xml
  • --tempdir=DIR — sets the temp dir where tests store their output files
  • --datadir=DIR — sets the data dir where tests read their reference files
  • --out=FILE — sends the test result to FILE instead of standard output
  • --append=FILE — appends the test result to FILE instead of standard output

There's a whole bunch of stuff available to you here that'll feel super familiar if you've ever looked at test.py. Which is lowkey expected, because the test-runner is literally just an interface between test.py and ns-3. One thing you might clock: the example-related commands are missing here. Why? Because examples are really not ns-3 tests. test.py runs them as if they were, just to keep a unified testing environment going, but fr fr they're completely different and you won't find them here.

The first new option that shows up here but not in test.py is --assert-on-failure. It's the moment when you're debugging a test case under a debugger like gdb. Pick it, and it tells the underlying test case to cause a segmentation violation the second an error is detected. Which has the cutest side-effect: program execution stops (breaks into the debugger) when an error is detected. If you're using gdb, you'd use this option something like:

$ ./waf shell
$ cd build/utils
$ gdb ns3-dev-test-runner-debug
$ run --suite=global-value --assert-on-failure

So if an error gets found in the global-value test suite, a segfault would be generated and the (source level) debugger would stop at the NS_TEST_ASSERT_MSG that detected the error. iconic behavior, no notes 💜

To run one of the tests directly from the test-runner using waf, you need to specify which test suite to run. So you could use the shell and do:

$ ./waf --run "test-runner --suite=pcap-file"

ns-3 logging is available when you run it this way, such as:

$ NS_LOG=”Packet” ./waf –run “test-runner –suite=pcap-file”

🌸 Test output 🌸

So many test suites need to write temporary files (pcap files, for example) while they're running the tests. Which means the tests need a temporary directory to write into. The Python test utility (test.py) will provide a temporary file automatically, but if you run stand-alone, that temporary directory has to be provided. And having to keep passing a --tempdir is genuinely annoying, so the test runner will figure one out for you if you don't hand it one. First it looks for environment variables named TMP and TEMP and uses those. If neither TMP nor TEMP is defined, it picks /tmp. Then the code tacks on an identifier telling you what created the directory (ns-3), then the time (hh.mm.ss), then a big random number. The test runner creates a directory of that name to be used as the temporary directory. Temporary files then go into a directory that'll be named something like

/tmp/ns-3.10.25.37.61537845

The time is there as a hint, so you can relatively easily reconstruct which directory got used if you need to go back and look at the files that were placed in there. 🕐 the detective work is built in

Another class of output is test output like pcap traces, generated so they can be compared to reference output. The test program will typically delete these after all the test suites run. To disable the deletion of test output, run test.py with the "retain" option:

$ ./test.py -r

and test output can be found in the testpy-output/ directory.

💖 Reporting of test failures 💖

When you run a test suite using the test-runner, it runs the test and reports PASS or FAIL. If you want it more quiet, you need to specify an output file that the tests will write their status into, using the --out option. Try:

$ ./waf --run "test-runner --suite=pcap-file --out=myfile.txt"

🦋 Debugging test suite failures 🦋

To debug test crashes, such as

CRASH: TestSuite wifi-interference

you can access the underlying test-runner program via gdb like this, and then pass the `--basedir=`pwd` argument to run (you can also pass other arguments as needed, but basedir is the minimum you need):

$ ./waf --command-template="gdb %s" --run "test-runner"
Waf: Entering directory `/home/tomh/hg/sep09/ns-3-allinone/ns-3-dev-678/build'
Waf: Leaving directory `/home/tomh/hg/sep09/ns-3-allinone/ns-3-dev-678/build'
'build' finished successfully (0.380s)
GNU gdb 6.8-debian
Copyright (C) 2008 Free Software Foundation, Inc.
L cense GPLv3+: GNU GPL version 3 or later <http://gnu.org/licenses/gpl.html>
This is free software: you are free to change and redistribute it.
There is NO WARRANTY, to the extent permitted by law.  Type "show copying"
and "show warranty" for details.
This GDB was configured as "x86_64-linux-gnu"...
(gdb) r --suite=
Starting program: <..>/build/utils/ns3-dev-test-runner-debug --suite=wifi-interference
[Thread debugging using libthread_db enabled]
assert failed. file=../src/core/model/type-id.cc, line=138, cond="uid <= m_information.size () && uid != 0"
...

And here's another example of how to use valgrind to debug a memory problem, such as:

VALGR: TestSuite devices-mesh-dot11s-regression

$ ./waf --command-template="valgrind %s --suite=devices-mesh-dot11s-regression" --run test-runner

👑 Class TestRunner 👑

The executables that run dedicated test programs use a TestRunner class. This class provides automatic test registration and listing, plus a way to execute the individual tests. Individual test suites use C++ global constructors to add themselves to a collection of test suites managed by the test runner. The test runner is what you use to list all the available tests and to select a test to be run. It's a quite simple class that provides three static methods for adding and getting test suites to a collection of tests. Go check the doxygen for class ns3::TestRunner for the details. ✨

✨ Test Suite ✨

All ns-3 tests are classified into Test Suites and Test Cases. A test suite is a collection of test cases that completely exercises a given kind of functionality. As described above, test suites can be classified as:

  • Build Verification Tests
  • Unit Tests
  • System Tests
  • Examples
  • Performance Tests

This classification is exported from the TestSuite class. This class is quite simple — it exists only as a place to export this type and to accumulate test cases. From a user's perspective, to create a new TestSuite in the system you literally just have to define a new class that inherits from class TestSuite and perform these two duties.

The following code will define a new class that can be run by test.py as a ''unit'' test with the display name, my-test-suite-name.

class MySuite : public TestSuite
{
public:
  MyTestSuite ();
};

MyTestSuite::MyTestSuite ()
  : TestSuite ("my-test-suite-name", UNIT)
{
  AddTestCase (new MyTestCase, TestCase::QUICK);
}

static MyTestSuite myTestSuite;

The base class takes care of all of the registration and reporting required to be a good citizen in the test framework. 🫶

Avoid putting initialization logic into the test suite or test case constructors. Why? Because an instance of the test suite is created at run time (thanks to that static variable up there) regardless of whether the test is being run or not. Instead, TestCase provides a virtual DoSetup method that can be specialized to perform setup before DoRun is called.

💅 Test Case 💅

Individual tests are created using a TestCase class. Common models for the use of a test case include "one test case per feature", and "one test case per method." Mixtures of these models may be used.

To create a new test case in the system, all you have to do is inherit from the TestCase base class, override the constructor to give the test case a name, and override the DoRun method to run the test. Optionally, override the DoSetup method too.

class MyTestCase : public TestCase
{
  MyTestCase ();
  virtual void DoSetup (void);
  virtual void DoRun (void);
};

MyTestCase::MyTestCase ()
  : TestCase ("Check some bit of functionality")
{
}

void
MyTestCase::DoRun (void)
{
  NS_TEST_ASSERT_MSG_EQ (true, true, "Some failure message");
}

🧚 Utilities 🧚

There are a number of utilities of various kinds that are also part of the testing framework. Examples include a generalized pcap file useful for storing test vectors; a generic container useful for transient storage of test vectors during test execution; and tools for generating presentations based on validation and verification testing results.

These utilities are not documented here, but for example, please see how the TCP tests found in src/test/ns3tcp/ use pcap files and reference output. go look, iykyk 🌿

✨ How to write tests ✨

okay bestie, listen up 💖 A huge goal of the ns-3 project is helping people make results that are actually valid and credible, and testing is a whole major chunk of that. If you're contributing models or examples, best believe someone's gonna ask for test code. Your models are gonna live for years with people using them who have zero clue at a glance whether they even work 😭 — and the test code you write is what saves them from future regressions and helps them understand the verification and where your model is even applicable.

There are sooo many ways to verify a model's implementation is correct. Down here we're covering some common cases you can use as a little guide when you're writing new tests 🫶

🎀 Sample TestSuite skeleton 🎀

Starting from absolute scratch (i.e. not adding a TestCase to an existing TestSuite)? Then you gotta decide these things up front, no skipping:

  • What the test suite will be called
  • What type of test it will be (Build Verification Test, Unit Test, System Test, or Performance Test)
  • Where the test code will live (either in an existing ns-3 module, or separately in the src/test/ directory). If it's a new file you will have to edit the wscript file in that directory to compile your new code.

There's a program called utils/create-module.py that's a great starting point. You invoke it like create-module.py router for a hypothetical new module called router. Once you do that, boom — you get a router directory and a test/router-test-suite.cc test suite 🌸 That file is your starting point for the initial test. It's a working test suite, even though the actual tests it does are trivial. Copy it over to your module's test directory and do a global substitution of "Router" in that file for something related to the model you wanna test. You can also edit stuff like making the test case name more descriptive ✨

You also need to add a block into your wscript so this test actually compiles:

module_test.source = [
    'test/router-test-suite.cc',
    ]

Before you make it do anything useful, it lowkey helps to try running the skeleton. Make sure ns-3 was configured with the "--enable-tests" option. Let's say your new test suite is called "router", like here:

RouterTestSuite::RouterTestSuite ()
  : TestSuite ("router", UNIT)

Try this command:

$ ./test.py -s router

And you should get output like this:

PASS: TestSuite router
1 of 1 tests passed (1 passed, 0 skipped, 0 failed, 0 crashed, 0 valgrind errors)

See src/lte/test/test-lte-antenna.cc for a worked example 💅

💜 Test macros 💜

There's a whole collection of macros for checking test program output against expected output. They live in src/core/model/test.h.

The main ones you'll be using:

NS_TEST_ASSERT_MSG_EQ(actual, limit, msg)
NS_TEST_ASSERT_MSG_NE(actual, limit, msg)
NS_TEST_ASSERT_MSG_LT(actual, limit, msg)
NS_TEST_ASSERT_MSG_GT(actual, limit, msg)
NS_TEST_ASSERT_MSG_EQ_TOL(actual, limit, tol, msg)

The first argument actual is the value under test, the second one limit is the expected value (or the value you're testing against), and the last argument msg is the error message printed out if the test fails.

The first four macros up there test for equality, inequality, less than, or greater than — respectively. The fifth one tests for equality but within a certain tolerance. That variant is your girl when you're testing floating point numbers for equality against a limit and you don't want a test failure just because of rounding errors 🙈

And finally, there are variants of all the above where the keyword ASSERT is swapped out for EXPECT. These are designed specially for use in methods (especially callbacks) that return void. Reserve them for callbacks you use in your test programs — otherwise, use the ASSERT variants. periodt ✨

🦋 How to add an example program to the test suite 🦋

There are two methods for adding an example program to the test suite. Normally you only add an example using one of these methods so you don't run the example twice 👑

First, you can "smoke test" that examples compile and run successfully all the way to completion (with no memory leaks) using the examples-to-run.py script in your module's test directory. Briefly: by including an instance of this file in your test directory, you make the test runner execute the examples you listed. Usually it's best to pick examples with reasonably short run times so you don't bog down the tests. See the example in src/lte/test/ directory. The exit status of the example gets checked when it runs, and a non-zero exit status can be used to say the example failed. This is the easiest way to add an example to the test suite, but it's got limited checks ngl.

Second, you can add an example to the test suite in a more complicated way that does enable checking of the example output (std::out and std::err). This approach uses the test suite framework with a specialized TestSuite or TestCase class designed to run an example and compare the output against a specified known "good" reference file. To use an example program as a test you need to create a test suite file and add it to the appropriate list in your module wscript file. The "good" output reference file needs to be generated so you can detect regressions.

If you're thinking about using this class, strongly consider using a standard test instead. The TestSuite class has better checking using the NS_TEST_* macros and in almost all cases is the better approach. If your test can be done with a TestSuite class you will be asked by the reviewers to rewrite the test when you do a pull request 💀

Let's say your module is called mymodule, and the example program is mymodule/examples/mod-example.cc. First you should create a test file mymodule/test/mymodule-examples-test-suite.cc that looks like this:

#include "ns3/example-as-test.h"
static ns3::ExampleAsTestSuite g_modExampleOne ("mymodule-example-mod-example-one", "mod-example", NS_TEST_SOURCEDIR, "--arg-one");
static ns3::ExampleAsTestSuite g_modExampleTwo ("mymodule-example-mod-example-two", "mod-example", NS_TEST_SOURCEDIR, "--arg-two");

The arguments to the constructor are the name of the test suite, the example to run, the directory that contains the "good" reference file (the macro NS_TEST_SOURCEDIR is normally the correct directory), and command line arguments for the example. In the code above the same example gets run twice with different arguments 🍓

Then you need to add that newly created test suite file to the list of test sources in mymodule/wscript. Building of examples is an option so you need to guard the inclusion of the test suite:

if (bld.env['ENABLE_EXAMPLES']):
   module.source.append('model/mymodule-examples-test-suite.cc')

Since you modified a wscript file you need to reconfigure and rebuild everything 😭

You just added new tests so you'll need to generate the "good" output reference files that'll be used to verify the example:

./test.py --suite="mymodule-example-*" --update

This runs all tests starting with "mymodule-example-" and saves new "good" reference files. Updating the reference files should be done when you create the test and whenever output changes. When you update the reference output you should inspect it to make sure it's actually valid 👀 The reference files should be committed with the new test.

That completes the process of adding a new example. slay 💅

You can now run the test with the standard test.py script. For example, to run the suites you just added:

./test.py --suite="mymodule-example-*"

This runs all mymodule-example-... tests and reports whether they produce output matching the reference files.

You can also add multiple examples as test cases to a TestSuite using ExampleAsTestCase. See src/core/test/examples-as-tests-test-suite.cc for examples of setting examples as tests.

When setting up an example for use by this class you should be very careful about what output the example generates. For example, writing output that includes simulation time (especially high resolution time) makes the test sensitive to potentially minor changes in event times. That makes the reference output hard to verify and hard to keep up-to-date. Output as little as needed for the example, and include only behavioral state that's important for determining if the example has run correctly. 💖

🌈 Testing for boolean outcomes 🌈

🌈 Testing outcomes when randomness is involved 🌈

🌈 Testing output data against a known distribution 🌈

🌈 Providing non-trivial input vectors of data 🌈

🌈 Storing and referencing non-trivial output data 🌈

🌈 Presenting your output test data 🌈


👑 SUPPORT 👑

🌟 Creating a new ns-3 model 🌟

This chapter walks through the design process of an ns-3 model, bestie. In a lot of research cases, users aren't satisfied just adapting existing models — they wanna extend the core of the simulator in a genuinely novel way 💅 So we're using the example of adding an ErrorModel to a simple ns-3 link as our motivating example, showing how you might approach this problem and go through a design and implementation.

NOTE: Documentation Here we focus on the process of creating new models and new modules, and some of the design choices involved. For the sake of clarity, we defer discussion of the mechanics of documenting models and source code to the Documentation chapter.

✨ Design Approach ✨

Think about how you want it to work; what it should do. Consider these things:

  • functionality: What functionality should it have? What attributes or configuration is exposed to the user?
  • reusability: How much should others be able to reuse my design? Can I reuse code from ns-2 to get started? How does a user integrate the model with the rest of another simulation?
  • dependencies: How can I reduce the introduction of outside dependencies on my new code as much as possible (to make it more modular)? For instance, should I avoid any dependence on IPv4 if I want it to also be used by IPv6? Should I avoid any dependency on IP at all?

Do not be hesitant to contact the ns-3-users or ns-developers list if you have questions 🫶 In particular, it's important to think about the public API of your new model and ask for feedback. It also helps to let others know about your work in case you're interested in collaborators ✨

Example: ErrorModel 💅

okay bestie, ns-2 already had an error model and it's so worth stealing the shape of. It lets packets get handed off to a stateful object that looks at a random variable and decides whether the packet got corrupted. Then the caller gets to decide what to do with it — drop it, whatever, their call. main character energy for a packet tbh.

The main API of the error model is a function you pass a packet to, and the return value is a boolean telling the caller whether any corruption happened. Heads up though: depending on which error model you're using, the packet data buffer may or may not actually be corrupted. Let's call this function IsCorrupt().

So far, in our design, we have:

class ErrorModel
{
public:
 /**
  * \returns true if the Packet is to be considered as errored/corrupted
  * \param pkt Packet to apply error model to
  */
  bool IsCorrupt (Ptr<Packet> pkt);
};

notice we do not pass a const pointer — that's on purpose, so the function is allowed to modify the packet if IsCorrupt() returns true. Not every error model will actually touch the packet, and whether or not the packet data buffer gets corrupted is something you should document. fr fr, write it down.

We may also want specialized versions of this, like ns-2 does. So — and yes, it's not the only design choice for polymorphism, but we're going with it — we assume we'll subclass a base class ErrorModel for specialized classes like RateErrorModel, ListErrorModel, etc., exactly like ns-2 does it.

At this point you might be like "why not just make IsCorrupt() a virtual method? 🥺" and that IS one approach. The other approach is to make the public non-virtual function indirect through a private virtual function — in C++ that's called the non virtual interface idiom, and it's what the ns-3 ErrorModel class adopted.

Next question: should this device have any dependencies on IP or other protocols? No thank you. We do not want to create dependencies on Internet protocols (the error model should be applicable to non-Internet protocols too), so we'll keep that in mind later. 🧚

Another consideration is how objects will include this error model. We're picturing an explicit setter in certain NetDevice implementations, like so:

/**
 * Attach a receive ErrorModel to the PointToPointNetDevice.
 *
 * The PointToPointNetDevice may optionally include an ErrorModel in
 * the packet receive chain.
 *
 * @see ErrorModel
 * @param em Ptr to the ErrorModel.
 */
void PointToPointNetDevice::SetReceiveErrorModel(Ptr<ErrorModel> em);

Again — not the only choice we have (error models could be aggregated to lots of other objects), but it satisfies our primary use case, which is letting a user force errors on otherwise successful packet transmissions, at the NetDevice level. 💖

After some thinking and looking at existing ns-2 code, here's a sample API of a base class and first subclass that could be posted for initial review:

class ErrorModel
{
public:
  ErrorModel ();
  virtual ~ErrorModel ();
  bool IsCorrupt (Ptr<Packet> pkt);
  void Reset (void);
  void Enable (void);
  void Disable (void);
  bool IsEnabled (void) const;
private:
  virtual bool DoCorrupt (Ptr<Packet> pkt) = 0;
  virtual void DoReset (void) = 0;
};

enum ErrorUnit
  {
    EU_BIT,
    EU_BYTE,
    EU_PKT
  };

// Determine which packets are errored corresponding to an underlying
// random variable distribution, an error rate, and unit for the rate.
class RateErrorModel : public ErrorModel
{
public:
  RateErrorModel ();
  virtual ~RateErrorModel ();
  enum ErrorUnit GetUnit (void) const;
  void SetUnit (enum ErrorUnit error_unit);
  double GetRate (void) const;
  void SetRate (double rate);
  void SetRandomVariable (const RandomVariable &ranvar);
private:
  virtual bool DoCorrupt (Ptr<Packet> pkt);
  virtual void DoReset (void);
};

Scaffolding 🏗️✨

so like, let's say you're ready to start implementing. You've got a fairly clear picture of what you want to build, and maybe you've already asked the list for initial review or suggestions. One way to approach the next step (implementation) is to create scaffolding and fill in the details as the design matures.

This section walks through a bunch of the steps you should consider to define scaffolding — i.e. a non-functional skeleton of what your model will eventually implement. Best practice: don't wait until the very end to get these details integrated. Instead, plumb a skeleton of your model into the system early and then add functions later once the API and integration seem about right. lowkey genius.

Heads up! You'll want to change a few things in the presentation below for your own model, because if you follow the error model verbatim, your code will collide with the existing error model. The below is just an outline of how ErrorModel was built that you can adapt to other models. 🙈

Review the ns-3 Coding Style Document 🎀

At this point, you may want to pause and read the ns-3 coding style document — especially if you're considering contributing your code back to the project. The coding style document is linked off the main project page: ns-3 coding style.

Decide Where in the Source Tree the Model Should Reside 🌳

All of the ns-3 model source code lives in the directory src/. You'll need to choose which subdirectory it resides in. If it's new model code of some sort, it makes sense to put it into the src/ directory somewhere — particularly for ease of integrating with the build system.

In the case of the error model, it's very related to the packet class, so it makes sense to implement this in the src/network/ module where ns-3 packets are implemented. perfect fit, no notes. 👑

waf and wscript 🔧

ns-3 uses the Waf build system. You will want to integrate your new

ns-3 uses the Waf build system. You will want to integrate your new source files into this system. This requires that you add your files to the wscript file found in each directory.

Let's start with empty files error-model.h and error-model.cc, and add this to src/network/wscript. It's really just a matter of adding the .cc file to the rest of the source files, and the .h file to the list of the header files.

Now, pop up to the top level directory and type ./test.py. You shouldn't have broken anything by this operation. 😌 (famous last words but ok)

Include Guards 🛡️

Next, let's add some include guards in our header file:

#ifndef ERROR_MODEL_H
#define ERROR_MODEL_H
...
#endif

namespace ns3 🌐

ns-3 uses the ns-3 namespace to isolate its symbols from other namespaces. Typically, a user will next put an ns-3 namespace block in both the cc and h file:

namespace ns3 {
...
}

At this point, we have some skeletal files in which we can start defining our new classes. The header file looks like this:

#ifndef ERROR_MODEL_H
#define ERROR_MODEL_H

namespace ns3 {

} // namespace ns3
#endif

while the error-model.cc file simply looks like this:

#include "error-model.h"

namespace ns3 {

} // namespace ns3

These files should compile since they don't really have any contents. We're now ready to start adding classes. 🦋

Initial Implementation 🚀

At this point, we're still working on some scaffolding, but we can begin to define our classes, with the functionality to be added later.

Inherit from the Object Class? 🤔

This is an important design step; whether to use class Object as a base class for your new classes.

As described in the chapter on the ns-3 Object-model, classes that inherit from class Object get special properties:

  • the ns-3 type and attribute system (see Attributes)
  • an object aggregation system
  • a smart-pointer reference counting system (class Ptr)

Classes that derive from class ObjectBase get the first two properties above, but do not get smart pointers. Classes that derive from class RefCountBase get only the smart-pointer reference counting system.

In practice, class Object is the variant of the three above that the ns-3 developer will most commonly encounter.

In our case, we want to make use of the attribute system, and we will be passing instances of this object across the ns-3 public API, so class Object is appropriate for us. slay, decision made. ✨

Initial Classes 🌸

One way to proceed is to start by defining the bare minimum functions and see if they'll compile. Let's review what all is needed to implement when we derive from class Object:

#ifndef ERROR_MODEL_H
#define ERROR_MODEL_H

#include "ns3/object.h"

namespace ns3 {

class ErrorModel : public Object
{
public:
  static TypeId GetTypeId (void);

  ErrorModel ();
  virtual ~ErrorModel ();
};

class RateErrorModel : public ErrorModel
{
public:
  static TypeId GetTypeId (void);

  RateErrorModel ();
  virtual ~RateErrorModel ();
};
#endif

A few things to note here. We need to include object.h. The convention in ns-3 is that if the header file is co-located in the same directory, it may be included without any path prefix. So if we were implementing ErrorModel in src/core/model directory, we could have just said #include "object.h". But we're in src/network/model, so we must include it as #include "ns3/object.h". Note also that this goes outside the namespace declaration.

Second, each class must implement a static public member function called GetTypeId (void).

Third, it's a good idea to implement constructors and destructors rather than letting the compiler generate them, and to make the destructor virtual. In C++, note also that the copy assignment operator and copy constructors are auto-generated if they're not defined — so if you do not want those, you should implement those as private members. This aspect of C++ is discussed in Scott Meyers' Effective C++ book, item 45. 📚

Let's now look at some corresponding skeletal implementation code in the .cc file:

#include "error-model.h"

namespace ns3 {

NS_OBJECT_ENSURE_REGISTERED (ErrorModel);

TypeId ErrorModel::GetTypeId (void)
{
  static TypeId tid = TypeId ("ns3::ErrorModel")
    .SetParent<Object> ()
    .SetGroupName ("Network")
    ;
  return tid;
}

ErrorModel::ErrorModel ()
{
}

ErrorModel::~ErrorModel ()
{
}

NS_OBJECT_ENSURE_REGISTERED (RateErrorModel);
TypeId RateErrorModel::GetTypeId (void)
{
  static TypeId tid = TypeId ("ns3::RateErrorModel")
    .SetParent<ErrorModel> ()
    .SetGroupName ("Network")
    .AddConstructor<RateErrorModel> ()
    ;
  return tid;
}

RateErrorModel::RateErrorModel ()
{
}

RateErrorModel::~RateErrorModel ()
{
}

so like, what even is the GetTypeId (void) function, bestie? It does a few things. It registers a unique string into the TypeId system. It establishes the hierarchy of objects in the attribute system (via SetParent). It also declares that certain objects can be created via the object creation framework (AddConstructor). ✨

The macro NS_OBJECT_ENSURE_REGISTERED (classname) is needed also once for every class that defines a new GetTypeId method, and it does the actual registration of the class into the system. The Object-model chapter discusses this in more detail. 👑


🦋 Including External Files

📝 Logging Support

Here, write a bit about adding |ns3| logging macros. Note that LOG_COMPONENT_DEFINE is done outside the namespace ns3

(yeah that one is literally a placeholder, the authors left themselves a lil sticky note and we're keeping it, no cap 💀)

🧵 Constructor, Empty Function Prototypes

🔑 Key Variables (Default Values, Attributes)

🧪 Test Program 1

🧸 Object Framework

🌈 Adding a Sample Script

At this point, one may want to try to take the basic scaffolding defined above and add it into the system. Performing this step now allows one to use a simpler model when plumbing into the system and may also reveal whether any design or API modifications need to be made. Once this is done, we will return to building out the functionality of the ErrorModels themselves. 💅

🛠️ Add Basic Support in the Class

/* point-to-point-net-device.h */
  class ErrorModel;

  /**
   * Error model for receive packet events
   */
  Ptr<ErrorModel> m_receiveErrorModel;

🚪 Add Accessor

void
PointToPointNetDevice::SetReceiveErrorModel (Ptr<ErrorModel> em)
{
  NS_LOG_FUNCTION (this << em);
  m_receiveErrorModel = em;
}
.AddAttribute ("ReceiveErrorModel",
                "The receiver error model used to simulate packet loss",
                PointerValue (),
                MakePointerAccessor (&PointToPointNetDevice::m_receiveErrorModel),
                MakePointerChecker<ErrorModel> ())

🚰 Plumb Into the System

void PointToPointNetDevice::Receive (Ptr<Packet> packet)
{
  NS_LOG_FUNCTION (this << packet);
  uint16_t protocol = 0;

  if (m_receiveErrorModel && m_receiveErrorModel->IsCorrupt (packet) )
    {
//
// If we have an error model and it indicates that it is time to lose a
// corrupted packet, don't forward this packet up, let it go.
//
      m_dropTrace (packet);
    }
  else
    {
//
// Hit the receive trace hook, strip off the point-to-point protocol header
// and forward this packet up the protocol stack.
//
      m_rxTrace (packet);
      ProcessHeader(packet, protocol);
      m_rxCallback (this, packet, protocol, GetRemote ());
      if (!m_promiscCallback.IsNull ())
        {           m_promiscCallback (this, packet, protocol, GetRemote (),
                      GetAddress (), NetDevice::PACKET_HOST);
        }
    }
}

📜 Create Null Functional Script

/* simple-error-model.cc */

  // Error model
  // We want to add an error model to node 3's NetDevice
  // We can obtain a handle to the NetDevice via the channel and node
  // pointers
  Ptr<PointToPointNetDevice> nd3 = PointToPointTopology::GetNetDevice
    (n3, channel2);
  Ptr<ErrorModel> em = Create<ErrorModel> ();
  nd3->SetReceiveErrorModel (em);

bool
ErrorModel::DoCorrupt (Packet& p)
{
  NS_LOG_FUNCTION;
  NS_LOG_UNCOND("Corrupt!");
  return false;
}

At this point, we can run the program with our trivial ErrorModel plumbed into the receive path of the PointToPointNetDevice. It prints out the string “Corrupt!” for each packet received at node n3. Next, we return to the error model to add in a subclass that performs more interesting error modeling. 🔥

👑 Add a Subclass

The trivial base class ErrorModel does not do anything interesting, but it provides a useful base class interface (Corrupt () and Reset ()), forwarded to virtual functions that can be subclassed. Let’s next consider what we call a BasicErrorModel which is based on the ns-2 ErrorModel class (in ns-2/queue/errmodel.{cc,h}). 🫶

What properties do we want this to have, from a user interface perspective? We would like for the user to be able to trivially swap out the type of ErrorModel used in the NetDevice. We would also like the capability to set configurable parameters.

Here are a few simple requirements we will consider:

  • Ability to set the random variable that governs the losses (default is UniformVariable)
  • Ability to set the unit (bit, byte, packet, time) of granularity over which errors are applied.
  • Ability to set the rate of errors (e.g. 10^-3) corresponding to the above unit of granularity.
  • Ability to enable/disable (default is enabled)

🪄 How to Subclass

We declare BasicErrorModel to be a subclass of ErrorModel as follows,:

class BasicErrorModel : public ErrorModel
{
public:
  static TypeId GetTypeId (void);
  ...
private:
  // Implement base class pure virtual functions
  virtual bool DoCorrupt (Ptr<Packet> p);
  virtual bool DoReset (void);
  ...
}

and configure the subclass GetTypeId function by setting a unique TypeId string and setting the Parent to ErrorModel:

TypeId RateErrorModel::GetTypeId (void)
{
  static TypeId tid = TypeId ("ns3::RateErrorModel")
    .SetParent<ErrorModel> ()
    .SetGroupName ("Network")
    .AddConstructor<RateErrorModel> ()
  ...

🧱 Build Core Functions and Unit Tests

✅ Assert Macros

✍️ Writing Unit Tests

🧩 Adding a New Module to ns-3

When you have created a group of related classes, examples, and tests, they can be combined together into an ns-3 module so that they can be used with existing ns-3 modules and by other researchers. 💖

This chapter walks you through the steps necessary to add a new module to ns-3.

🗂️ Step 0 - Module Layout

All modules can be found in the src directory. Each module can be found in a directory that has the same name as the module. For example, the spectrum module can be found here: src/spectrum. We’ll be quoting from the spectrum module for illustration.

A prototypical module has the following directory structure and required files:

src/
  module-name/
    bindings/
    doc/
    examples/
      wscript
    helper/
    model/
    test/
      examples-to-run.py
    wscript

Not all directories will be present in each module.

🐣 Step 1 - Create a Module Skeleton

A python program is provided in the utils directory that will create a skeleton for a new module. For the purposes of this discussion we will assume that your new module is called new-module. From the top directory, do the following to create the new module:

$ ./utils/create-module.py new-module

By default create-module.py creates the module skeleton in the src directory. However, it can also create modules in contrib:

$ ./utils/create-module.py contrib/new-contrib

Let’s assume we’ve created our new module in src. cd into src/new-module; you will find this directory layout:

$ cd new-module
$ ls
doc examples  helper  model  test  wscript

In more detail, the create-module.py script will create the directories as well as initial skeleton wscript, .h, .cc and .rst files. The complete module with skeleton files looks like this:

src/
  new-module/
    doc/
      new-module.rst
    examples/
      new-module-example.cc
      wscript
    helper/
      new-module-helper.cc
      new-module-helper.h
    model/
      new-module.cc
      new-module.h
    test/
      new-module-test-suite.cc
    wscript

(If required the bindings/ directory listed in Step-0 will be created automatically during the build.)

We next walk through how to customize this module. Informing waf about the files which make up your module is done by editing the two wscript files. We will walk through the main steps in this chapter.

All ns-3 modules depend on the core module and usually on other modules. This dependency is specified in the wscript file (at the top level of the module, not the separate wscript file in the examples directory!). In the skeleton wscript the call that will declare your new module to waf will look like this (before editing):

def build(bld):
    module = bld.create_ns3_module('new-module', ['core'])

Let’s assume that new-module depends on the internet, mobility, and aodv modules. After editing it the wscript file should look like:

def build(bld):
    module = bld.create_ns3_module('new-module', ['internet', 'mobility', 'aodv'])

Note that only first level module dependencies should be listed, which is why we removed core; the internet module in turn depends on core. 🌟

Your module will most likely have model source files. Initial skeletons (which will compile successfully) are created in model/new-module.cc and model/new-module.h.

If your module will have helper source files, then they will go into the helper/ directory; again, initial skeletons are created in that directory.

Finally, it is good practice to write tests and examples. These will almost certainly be required for new modules to be accepted into the official ns-3 source tree. A skeleton test suite and test case is created in the test/ directory. The skeleton test suite will contain the below constructor, which declares a new unit test named new-module, with a single test case consisting of the class NewModuleTestCase1:

NewModuleTestSuite::NewModuleTestSuite ()
  : TestSuite ("new-module", UNIT)
{
  AddTestCase (new NewModuleTestCase1);
}

📦 Step 3 - Declare Source Files

The public header and source code files for your new module should be specified in the wscript file by modifying it with your text editor.

As an example, after declaring the spectrum module, the src/spectrum/wscript specifies the source code files with the following list:

def build(bld):
module = bld.create_ns3_module('spectrum', ['internet', 'propagation', 'antenna', 'applications'])

module.source = [
    'model/spectrum-model.cc',
    'model/spectrum-value.cc',
          .
          .
          .
    'model/microwave-oven-spectrum-value-helper.cc',
    'helper/spectrum-helper.cc',
    'helper/adhoc-aloha-noack-ideal-phy-helper.cc',
    'helper/waveform-generator-helper.cc',
    'helper/spectrum-analyzer-helper.cc',
    ]

So bestie, everything you list in module.source gets compiled and smooshed together into a link library 🫶, and that library gets linked into literally every program that depends on this module. Like, it's the whole point.

But wait — how do those programs find out what the public API of your brand-new module even is? 👀 Read on, queen.


✨ Step 4 — Declare Public Header Files ✨

The header files that define the public API of your model and your helpers also need to be listed in the wscript file. No skipping. 💅

Still rolling with the spectrum model as our example, the public headers get declared like this. (Also bestie, peep that the argument you hand to bld is what tells waf to install this module's headers right alongside all the other ns-3 headers 👑):

headers = bld(features='ns3header')

headers.module = 'spectrum'

headers.source = [
    'model/spectrum-model.h',
    'model/spectrum-value.h',
           .
           .
           .
    'model/microwave-oven-spectrum-value-helper.h',
    'helper/spectrum-helper.h',
    'helper/adhoc-aloha-noack-ideal-phy-helper.h',
    'helper/waveform-generator-helper.h',
    'helper/spectrum-analyzer-helper.h',
    ]

Headers you make public this way become reachable for anyone using your model, with include statements like:

#include "ns3/spectrum-model.h"

Headers that are strictly internal to your implementation? Do not put them in that list, bestie, that's not their moment 🙈. They're still totally reachable from your own implementation code with include statements like:

#include "my-module-implementation.h"

✨ Step 5 — Declare Tests ✨

If your shiny new module has tests (and like… it should 🥺), you have to declare them in your wscript file by editing it with your text editor.

The spectrum model's tests get declared with this stanza:

module_test = bld.create_ns3_module_test_library('spectrum')

module_test.source = [
    'test/spectrum-interference-test.cc',
    'test/spectrum-value-test.cc',
    ]

Want the deets on actually writing test cases? Go peek at Tests. 🌸


✨ Step 6 — Declare Examples ✨

Got examples in your new module? Then they have to be declared in your examples/wscript file. (Heads up: the skeleton top-level wscript only recursively pulls in examples/wscript if the examples were enabled at configure time. So like, if you didn't enable them, don't be shocked when they don't show up 💀.)

The spectrum model defines its first example in src/spectrum/examples/wscript with:

def build(bld):
  obj = bld.create_ns3_program('adhoc-aloha-ideal-phy',
                               ['spectrum', 'mobility'])
  obj.source = 'adhoc-aloha-ideal-phy.cc'

Okay so the second argument to create_ns3_program() is the list of modules that the program you're creating depends on — and yes, do not forget to include new-module in that list 😭. Best practice is to list only the direct module dependencies and let waf work out the full dependency tree for you. It's giving "let the compiler girlies handle it". ✨

Sometimes, for clarity, you might wanna split your example's implementation across several source files. In that case just toss those files in as additional explicit sources of the example:

obj = bld.create_ns3_program('new-module-example', [new-module])
obj.source = ['new-module-example.cc', 'new-module-example-part.cc']

Python examples get declared with this function call. And again — the second argument to register_ns3_script() is the list of modules your Python example depends on:

bld.register_ns3_script('new-module-example.py', ['new-module'])

✨ Step 7 — Examples Run as Tests ✨

Besides running explicit test code, the test framework can also be instrumented to run full example programs to try and catch regressions in the examples. Obsessed, honestly. But ngl, not every example is suitable for regression tests. The file test/examples-to-run.py controls when the examples get invoked while the test framework is running.

The spectrum model examples that get run by test.py are specified in src/spectrum/test/examples-to-run.py using these two lists of C++ and Python examples:

# A list of C++ examples to run in order to ensure that they remain
# buildable and runnable over time.  Each tuple in the list contains
#
#     (example_name, do_run, do_valgrind_run).
#
# See test.py for more information.
cpp_examples = [
    ("adhoc-aloha-ideal-phy", "True", "True"),
    ("adhoc-aloha-ideal-phy-with-microwave-oven", "True", "True"),
    ("adhoc-aloha-ideal-phy-matrix-propagation-loss-model", "True", "True"),
]

# A list of Python examples to run in order to ensure that they remain
# runnable over time.  Each tuple in the list contains
#
#     (example_name, do_run).
#
# See test.py for more information.
python_examples = [
    ("sample-simulator.py", "True"),
]

Like the comment literally says, every entry in the C++ list of examples to run holds the tuple (example_name, do_run, do_valgrind_run), where:

  • example_name is the executable to be run,
  • do_run is a condition under which to run the example, and
  • do_valgrind_run is a condition under which to run the example under valgrind. (This one is needed because NSC causes illegal instruction crashes with some tests when they're run under valgrind. 💀 not a vibe.)
Also note: those two conditions are Python statements that can depend on waf configuration variables. So you can get cute with them.

For example:

("tcp-nsc-lfn", "NSC_ENABLED == True", "NSC_ENABLED == False"),

And every entry in the Python list of examples to run holds the tuple (example_name, do_run) — where, same as the C++ examples:

  • example_name is the Python script to be run, and
  • do_run is a condition under which to run the example.

Again, that condition is a Python statement that can depend on waf configuration variables. Like:

("realtime-udp-echo.py", "ENABLE_REAL_TIME == False"),

✨ Step 8 — Configure and Build ✨

Now you can configure, build and test your module like normal, bestie. 🎀 But you must reconfigure the project as your first step, so that waf caches all the new info in your wscript files — otherwise your new module simply won't be included in the build and you'll be sitting there like 🧍‍♀️ wondering why nothing happened.

$ ./waf configure --enable-examples --enable-tests
$ ./waf build
$ ./test.py

Then go look for your new module's test suite (and example programs too, if your module has any enabled) in the test output. Slay. 👑


✨ Step 9 — Python Bindings ✨

Adding Python bindings to your module is optional, and this step is commented out by default in the create-module.py script:

# bld.ns3_python_bindings()

If you do want Python bindings (which you only need if you wanna write Python ns-3 programs instead of C++ ns-3 programs 🐍), you should uncomment the line above, install the Python API scanning system (it's covered elsewhere in this manual, iykyk), and scan your module to generate the new bindings.


💖 Creating Documentation 💖

ns-3 hands you two whole flavors of documentation:

  • expository “user-guide”-style chapters, and
  • source code API documentation.

The “user-guide” chapters are written by hand in reStructuredText format (.rst), and the Python documentation system Sphinx processes them into web pages and pdf files. The API documentation, meanwhile, is generated straight from the source code itself using Doxygen, producing cross-linked web pages.

Both of these matter, fr fr: the Sphinx chapters explain the why and the overview of using a model, while the API documentation explains the how details. Two different girlies, two different jobs. 🫶

This chapter gives you a quick overview of these tools, with extra emphasis on the preferred usage and customizations for ns-3.

Wanna build all the standard documentation? Say less:

$ ./waf docs

For the more specialized options, keep reading bestie. ✨


🦋 Documenting with Sphinx 🦋

We use Sphinx to generate expository chapters describing the design and usage of each module. (You are literally reading the Documentation Chapter right now, hi 🙈.) And if you're reading the html version, that Show Source link in the sidebar will show you the reStructuredText source for this very chapter.


🌸 Adding New Chapters 🌸

Adding a new chapter takes three steps (each one explained in more detail below):

  1. Choose Where? the documentation file(s) will live.
  2. Link from an existing page to the new documentation.
  3. Add the new file to the Makefile.

🗺️ Where?

Documentation for a specific module, foo, should normally live in src/foo/doc/. For example, src/foo/doc/foo.rst would be the top-level document for the module. And good news — the utils/create-module.py script will create this file for you. 💅

Some models need several .rst files, plus figures, and all of those should go in the src/foo/doc/ directory too. The docs are actually built by a Sphinx Makefile. For especially involved documentation, it can be helpful to have a local Makefile in the src/foo/doc/ directory to simplify building the documentation for that module (Antenna is an example of this). Setting that up is not particularly hard, but it's beyond the scope of this chapter, sorry bestie. 🙈

And in some cases documentation spans multiple models — the Network chapter is an example of that. In those cases, adding the .rst files directly to doc/models/source/ might be the appropriate move.

Sphinx has to know where your new chapter should appear. In most cases a new model chapter should appear in the Models book. To add your chapter there, edit doc/models/source/index.rst:

.. toctree::
   :maxdepth: 1

  organization
  animation
  antenna
  aodv
  applications
  ...

Add the name of your document (without the .rst extension) to this list. And please keep the Model chapters in alphabetical order so people can actually visually scan for the chapter they want. 🥺

🛠️ Makefile

You also have to add your document to the appropriate Makefile, so that make knows to check it for updates. The Models book Makefile is doc/models/Makefile, and the Manual book Makefile is doc/manual/Makefile.

# list all model library .rst files that need to be copied to $SOURCETEMP
SOURCES = \
        source/conf.py \
        source/_static \
        source/index.rst \
        source/replace.txt \
        source/organization.rst \
        ...
        $(SRC)/antenna/doc/source/antenna.rst \
        ...

You add your .rst files to the SOURCES variable. Wanna add figures? Read the comments in the Makefile to see which variable is supposed to hold your image files. And again — please keep these in alphabetical order, bestie. It's the little things. ✨

✨ Building Sphinx Docs ✨

okay bestie, building the Sphinx docs is lowkey so easy. To build all the Sphinx documentation, you just go:

$ ./waf sphinx

And if you only want the Models documentation? Slay, do this instead:

$ make -C doc/models html

Then to actually <em>see</em> the generated docs, point your browser at doc/models/build/html and vibe with it. 👑

See how Sphinx uses Make to guide the whole process? The default target builds every enabled output form, which in ns-3 means the multi-page html, the single-page singlehtml, and pdf (latex). If you only want the multi-page html, you add the html target:

$ make -C doc/models html

This is genuinely helpful when you're writing your chapter, because it cuts down the build time and the size of all that build chatter. Fewer words on screen = more brain for writing, periodt.

💖 Before you commit your documentation to the repo, please, PLEASE check that it builds with no errors and no warnings. The build process spews out a LOT of output (mostly normal chatter from LaTeX), which makes it genuinely hard to spot Sphinx warnings or errors hiding in there. To find the important ones, build just the html version, then search the build log for warning or error.

🌸 ns-3 Specifics 🌸

The Sphinx documentation and the tutorial are pretty good, ngl. So we're not gonna duplicate the basics here — instead we're focusing on the preferred usage for ns-3. 💅

  • Start documents with these two lines:
.. include:: replace.txt
.. highlight:: cpp

The first line enables some simple replacements — for example, typing |ns3| renders as ns-3. The second one sets the default source code highlighting language explicitly for the file, because the parser's guess isn't always accurate. (It's also possible to set the language explicitly for a single code block — see below, bestie.)

  • Sections:

Sphinx is pretty liberal about marking section headings, but by convention we prefer this hierarchy:

.. heading hierarchy:
   ------------- Chapter
   ************* Section (#.#)
   ============= Subsection (#.#.#)
   ############# Sub-subsection
  • Syntax Highlighting:

To use the default syntax highlighter, simply start a sourcecode block:

  • Sphinx Source:
             The ``Frobnitz`` is accessed by::

               Foo::Frobnitz frob;
               frob.Set (...);
  • Rendered Output:
The Frobnitz is accessed by:

            Foo::Frobnitz frob;
            frob.Set (...);

To use a specific syntax highlighter — for example, bash shell commands:

  • Sphinx Source:
            .. sourcecode:: bash

               $ ls
  • Rendered Output:
            $ ls
  • Shorthand Notations:

These shorthands are defined, bestie, and you're gonna use them constantly:

  • |ns3| — renders as ns-3
  • |ns2| — renders as ns-2
  • |check| — renders as \checkmark
  • :rfc:`6282` — renders as RFC 6282

Like, don't type the long version ever again. Use the shorthand. Periodt. 💅


✨ Documenting with Doxygen ✨

We use Doxygen to generate browsable API documentation, and ngl it's kind of a big deal. Doxygen provides a number of useful features:

  • A summary table of all class members. 🗂️
  • Graphs of inheritance and collaboration for all classes. 🌳
  • Links to the source code implementing each function.
  • Links to every place a member is used. 👀
  • Links to every object used in implementing a function.
  • Grouping of related classes, such as all the classes related to a specific protocol.

In addition, we use the TypeId system to add to the documentation for every class:

  • The Config paths by which such objects can be reached.
  • Documentation for any Attributes, including Attributes defined in parent classes. (Yes, the inherited ones too, we love an overachiever.)
  • Documentation for any Trace sources defined by the class.
  • The memory footprint for each class.

Doxygen operates by scanning the source code, looking for specially marked comments. It also creates a cross reference, indicating where each file, class, method, and variable is used.


🎀 Preferred Style 🎀

The preferred style for Doxygen comments is the JavaDoc style:

/**
 *  Brief description of this class or method.
 *  Adjacent lines become a single paragraph.
 *
 *  Longer description, with lots of details.
 *
 *  Blank lines separate paragraphs.
 *
 *  Explain what the class or method does, using what algorithm.
 *  Explain the units of arguments and return values.
 *
 *  \note Note any limitations or gotchas.
 *
 *  (For functions with arguments or return valued:)
 *  \param [in] foo Brief noun phrase describing this argument. Note
 *                  that we indicate if the argument is input,
 *                  output, or both.
 *  \param [in,out] bar Note Sentence case, and terminating period.
 *  \param [in] baz Indicate boolean values with \c true or \c false.
 *  \return Brief noun phrase describing the value.
 *
 *  \internal
 *
 *  You can also discuss internal implementation details.
 *  Understanding this material shouldn't be necessary to using
 *  the class or method.
 */
void ExampleFunction (const int foo, double & bar, const bool baz);

In this style the Doxygen comment block begins with two * characters: /**, and precedes the item being documented. Simple as that. 🫶

For items needing only a brief description, either of these short forms is appropriate:

/** Destructor implementation. */
void DoDispose ();

int m_count;  //!< Count of ...

Note the special form of the end of line comment, //!<, indicating that it refers to the preceding item. Lowkey elegant, honestly. ✨

Some items to note:

  • Use sentence case, including the initial capital.
  • Use punctuation, especially .'s at the end of sentences or phrases.
  • The \brief tag is not needed; the first sentence will be used as the brief description.

Every class, method, typedef, member variable, function argument and return value should be documented in all source code files which form the formal API and implementation for ns-3, such as src/<module>/model/*, src/<module>/helper/* and src/<module>/utils/*. Documentation for items in src/<module>/test/* and src/<module>/examples/* is preferred, but not required. (Preferred though. Do it. 👑)


🌈 Useful Features 🌈

  • Inherited members will automatically inherit docs from the parent, (but can be replaced by local documentation).
    1. Document the base class.
    2. In the sub class mark inherited functions with an ordinary comment:
// Inherited methods
virtual void FooBar (void);
virtual int BarFoo (double baz);

Note that the signatures have to match exactly, so include the formal argument (void)

This doesn't work for static functions; see GetTypeId, below, for an example. 💀


🔥 Building Doxygen Docs 🔥

Building the Doxygen documentation is pretty simple:

$ ./waf doxygen

This builds using the default configuration, which generates documentation sections for all items, even if they do not have explicit comment documentation blocks. This has the effect of suppressing warnings for undocumented items, but makes sure everything appears in the generated output, which is usually what you want for general use. Note that we generate documentation even for modules which are disabled, to make it easier to see all the features available in ns-3. (FOMO is not the vibe here — we show you everything.) 🌸

When writing documentation, it's often more useful to see which items are generating warnings, typically about missing documentation. To see the full warnings list, use the doc/doxygen.warnings.report.sh script:

$ doc/doxygen.warnings.report.sh

doxygen.warnings.report.sh:
Building and running print-introspected-doxygen...done.
Rebuilding doxygen (v1.8.10) docs with full errors...done.

Report of Doxygen warnings
----------------------------------------

(All counts are lower bounds.)

Warnings by module/directory:

Count Directory
----- ----------------------------------
3414 src/lte/model
1532 src/wimax/model
 825 src/lte/test
....
   1 src/applications/test
 97 additional undocumented parameters.
----------------------------------------
 12460 total warnings
   100 directories with warnings

Warnings by file (alphabetical)

Count File
----- ----------------------------------
  15 examples/routing/manet-routing-compare.cc
  26 examples/stats/wifi-example-apps.h
  12 examples/tutorial/fifth.cc
....
  17 utils/python-unit-tests.py
----------------------------------------
   771 files with warnings

Warnings by file (numerical)

Count File
----- ----------------------------------
 273 src/lte/model/lte-rrc-sap.h
 272 src/core/model/simulator.h
 221 src/netanim/model/animation-interface.h
....
   1 src/wimax/model/ul-job.cc
----------------------------------------
   771 files with warnings

Doxygen Warnings Summary
----------------------------------------
   100 directories
   771 files
 12460 warnings

(This snippet has a lot of lines suppressed!)

The script lowkey tweaks the configuration so all the warnings show up, and it shortens the run time too. (It shortens the run time mainly by disabling creation of diagrams — think call trees — and by not generating documentation for undocumented items, bc generating those docs would... defeat the whole purpose of triggering the warnings 💀.) And as you can see, at this writing we have a lot of undocumented items. The report summarizes warnings by module src/*/*, and by file, in alphabetical and numerical order. ✨

The script has a few options to pare things down and make the output more manageable. For help, use the -h option. Once you've run it to do the Doxygen build and generate the full warnings log, you can reprocess the log file with various "filters" — no need to redo the full Doxygen build — by using the -s option. You can exclude warnings from */examples/* files (-e option), and/or */test/* files (-t). Just to be clear bestie: all of the filter options do the complete fast doxygen build; they just filter the doxygen log and warnings output.

Perhaps the most useful option when writing documentation comments is -m <module>, which will limit the report to just files matching src/<module>/*, and follow the report with the actual warning lines. Combine with -et and you can focus on the warnings that are most urgent in a single module:

$ doc/doxygen.warnings.report.sh -m mesh/helper
...
Doxygen Warnings Summary
----------------------------------------
  1 directories
  3 files
149 warnings

Filtered Warnings
========================================
src/mesh/helper/dot11s/dot11s-installer.h:72: warning: Member m_root (variable) of class ns3::Dot11sStack is not documented.
src/mesh/helper/dot11s/dot11s-installer.h:35: warning: return type of member ns3::Dot11sStack::GetTypeId is not documented
src/mesh/helper/dot11s/dot11s-installer.h:56: warning: return type of member ns3::Dot11sStack::InstallStack is not documented
src/mesh/helper/flame/lfame-installer.h:40: warning: Member GetTypeId() (function) of class ns3::FlameStack is not documented.
src/mesh/helper/flame/flame-installer.h:60: warning: return type of member ns3::FlameStack::InstallStack is not documented
src/mesh/helper/mesh-helper.h:213: warning: Member m_nInterfaces (variable) of class ns3::MeshHelper is not documented.
src/mesh/helper/mesh-helper.h:214: warning: Member m_spreadChannelPolicy (variable) of class ns3::MeshHelper is not documented.
src/mesh/helper/mesh-helper.h:215: warning: Member m_stack (variable) of class ns3::MeshHelper is not documented.
src/mesh/helper/mesh-helper.h:216: warning: Member m_stackFactory (variable) of class ns3::MeshHelper is not documented.
src/mesh/helper/mesh-helper.h:209: warning: parameters of member ns3::MeshHelper::CreateInterface are not (all) documented
src/mesh/helper/mesh-helper.h:119: warning: parameters of member ns3::MeshHelper::SetStandard are not (all) documented

Finally, note that undocumented items (classes, methods, functions, typedefs, etc.) won't produce documentation when you build with doxygen.warnings.report.sh, and only the outermost item will produce a warning. So as a result, if you don't see documentation for a class method in the generated documentation, the class itself probably needs documentation. 🙈

Now it's just a matter of understanding the code, and writing some docs! 💅


ns-3 Specifics 🦋

As for Sphinx, the Doxygen docs and reference are pretty good. We won't duplicate the basics here, instead focusing on preferred usage for ns-3.

  • Use Doxygen Modules to group related items. 👑

In the main header for a module, create a Doxgyen group:

/**
 *  \defgroup foo Foo protocol.
 *  Implementation of the Foo protocol.
 */

The symbol foo is how other items can add themselves to this group. The string following that will be the title for the group. Any further text will be the detailed description for the group page.

  • Document each file, assigning it to the relevant group. In a header file:
/**
 *  \file
 *  \ingroup foo
 *  Class Foo declaration.
 */

or in the corresponding .cc file:

/**
 *  \file
 *  \ingroup foo
 *  Class FooBar implementation.
 */
  • Mark each associated class as belonging to the group:
/**
 *  \ingroup foo
 *
 *  FooBar packet type.
*/
class FooBar
  • Did you know typedefs can have formal arguments? 🤯 This enables documentation of function pointer signatures:
/**
 *  Bar callback function signature.
 *
 *  \param ale The size of a pint of ale, in Imperial ounces.
 */
typedef void (* BarCallback)(const int ale);
  • Copy the Attribute help strings from the GetTypeId method to use as the brief descriptions of associated members.
  • \bugid{298} will create a link to bug 298 in our Bugzilla.
  • \p foo in a description will format foo the same as the \param foo parameter, making it clear that you are referring to an actual argument.
  • \RFC{301} will create a link to RFC 301.
  • Document the direction of function arguments with \param [in], etc. The allowed values of the direction token are [in], [out], and [in,out] (note the explicit square brackets), as discussed in the Doxygen docs for \param.
  • Document template arguments with \tparam, just as you use \param for function arguments.
  • For template arguments, indicate if they will be deduced or must be given explicitly:
/**
 * A templated function.
 * \tparam T \explicit The return type.
 * \tparam U \deduced The argument type.
 * \param [in] a The argument.
 */
template <typename T, typename U> T Function (U a);
  • Use \tparam U \deduced because the type U can be deduced at the site where the template is invoked. Basically deduction can only be done for function arguments.
  • Use \tparam T \explicit because the type T can't be deduced; it must be given explicitly at the invocation site, as in Create<MyObject> (...)
  • \internal should be used only to set off a discussion of implementation details, not to mark private functions (they are already marked, as private!) 💅
  • Don't create classes with trivial names, such as class A, even in test suites. These cause all instances of the class name literal A to be rendered as links. 💀

As noted above, static functions don't inherit the documentation of the same functions in the parent class. ns-3 uses a few static functions ubiquitously; the suggested documentation block for these cases is:

  • Default constructor/destructor:
MyClass ();   //!< Default constructor
~MyClass ();  //!< Destructor
  • Dummy destructor and DoDispose:
/** Dummy destructor, see DoDispose. */
~MyClass ();

/** Destructor implementation */
virtual void DoDispose ();
  • GetTypeId:
/**
 *  Register this type.
 *  \return The object TypeId.
 */
static TypeId GetTypeId (void);

Enabling Subsets of ns-3 Modules 🧚

As with most software projects, ns-3 is ever growing larger in terms of number of modules, lines of code, and memory footprint. Users, however, may only use a few of those modules at a time. For this reason, users may want to explicitly enable only the subset of the possible ns-3 modules that they actually need for their research. 🎀

This chapter discusses how to enable only the ns-3 modules that you are interested in using.


How to enable a subset of ns-3's modules

If shared libraries are being built, then enabling a module will cause at least one library to be built:

libns3-modulename.so

If the module has a test library and test libraries are being built, then

libns3-modulename-test.so

will be built, too. Other modules that the module depends on and their test libraries will also be built.

By default, all modules are built in ns-3. There are two ways to enable a subset of these modules:

  1. Using waf's –enable-modules option
  2. Using the ns-3 configuration file

Enable modules using waf's –enable-modules option

To enable only the core module with example and tests, for example, try these commands:

$ ./waf clean
$ ./waf configure --enable-examples --enable-tests --enable-modules=core
$ ./waf build
$ cd build/debug/
$ ls

and the following libraries should be present:

bindings  libns3-core.so       ns3      scratch  utils
examples  libns3-core-test.so  samples  src
Note the ./waf clean step is done here only to make it more obvious which module libraries were built. You don't have to do ./waf clean in order to enable subsets of modules. 🫶

Running test.py will cause only those tests that depend on module core to be run:

24 of 24 tests passed (24 passed, 0 skipped, 0 failed, 0 crashed, 0 valgrind errors)

Repeat the above steps for the "network" module instead of the "core" module, and the following will be built, since network depends on core:

bindings  libns3-core.so       libns3-network.so       ns3      scratch  utils
examples  libns3-core-test.so  libns3-network-test.so  samples  src

Running test.py will cause those tests that depend on only the core and network modules to be run:

31 of 31 tests passed (31 passed, 0 skipped, 0 failed, 0 crashed, 0 valgrind errors)

Enable modules using the ns-3 configuration file

A configuration file, .ns3rc, has been added to ns-3 that allows users to specify which modules are to be included in the build. ✨

When enabling a subset of ns-3 modules, the precedence rules are as follows:

  1. the –enable-modules configure string overrides any .ns3rc file
  2. the .ns3rc file in the top level ns-3 directory is next consulted, if present
  3. the system searches for ~/.ns3rc if the above two are unspecified

If none of the above limits the modules to be built, all modules that waf knows about will be built.

The maintained version of the .ns3rc file in the ns-3 source code repository resides in the utils directory. The reason for this is if it were in the top-level directory of the repository, it would be prone to accidental checkins from maintainers that enable the modules they want to use. 😭 Therefore, users need to manually copy the .ns3rc from the utils directory to their preferred place (top level directory or their home directory) to enable persistent modular build configuration.

Assuming that you are in the top level ns-3 directory, you can get a copy of the .ns3rc file that is in the utils directory as follows:

$ cp utils/.ns3rc .

The .ns3rc file should now be sitting pretty in your top level ns-3 directory, and it's giving:

#! /usr/bin/env python

# A list of the modules that will be enabled when ns-3 is run.
# Modules that depend on the listed modules will be enabled also.
#
# All modules can be enabled by choosing 'all_modules'.
modules_enabled = ['all_modules']

# Set this equal to true if you want examples to be run.
examples_enabled = False

# Set this equal to true if you want tests to be run.
tests_enabled = False

Okay so now grab your favorite editor and glow it up so it only enables the core module, with examples and tests on, like this:

#! /usr/bin/env python

# A list of the modules that will be enabled when ns-3 is run.
# Modules that depend on the listed modules will be enabled also.
#
# All modules can be enabled by choosing 'all_modules'.
modules_enabled = ['core']

# Set this equal to true if you want examples to be run.
examples_enabled = True

# Set this equal to true if you want tests to be run.
tests_enabled = True

Now only the core module is enabled, bestie, when you try these commands:

$ ./waf clean
$ ./waf configure
$ ./waf build
$ cd build/debug/
$ ls

and these libraries should be present:

bindings  libns3-core.so       ns3      scratch  utils
examples  libns3-core-test.so  samples  src
Lowkey important: the ./waf clean step is done here only to make it more obvious which module libraries got built. You do NOT have to do ./waf clean in order to enable subsets of modules. 🫶

Running test.py will cause only those tests that depend on module core to be run:

24 of 24 tests passed (24 passed, 0 skipped, 0 failed, 0 crashed, 0 valgrind errors)

Now repeat the whole thing but for the network module instead of the core module, and the following will be built, since network depends on core:

bindings  libns3-core.so       libns3-network.so       ns3      scratch  utils
examples  libns3-core-test.so  libns3-network-test.so  samples  src

Running test.py will cause those tests that depend on only the core and network modules to be run:

31 of 31 tests passed (31 passed, 0 skipped, 0 failed, 0 crashed, 0 valgrind errors)

🦋 Enabling/disabling ns-3 Tests and Examples

The ns-3 distribution ships with a ton of examples and tests that are used to validate the ns-3 system. But like… users don't always want those examples and tests to be run for their own installation of ns-3, and that's valid. 💅

This chapter discusses how to build ns-3 with or without its examples and tests.

✨ How to enable/disable examples and tests in ns-3

There are 3 ways to enable/disable examples and tests in ns-3:

  1. Using build.py when ns-3 is built for the first time
  2. Using waf once ns-3 has been built
  3. Using the ns-3 configuration file once ns-3 has been built

🛠️ Enable/disable examples and tests using build.py

You can use build.py to enable/disable examples and tests when ns-3 is built for the first time.

By default, examples and tests are not built in ns-3.

From the ns-3-allinone directory, you can build ns-3 without any examples or tests simply by doing:

$ ./build.py

Running test.py in the top level ns-3 directory now will cause no examples or tests to be run:

0 of 0 tests passed (0 passed, 0 skipped, 0 failed, 0 crashed, 0 valgrind errors)

If you would like build ns-3 with examples and tests, then do the following from the ns-3-allinone directory:

$ ./build.py --enable-examples --enable-tests

Running test.py in the top level ns-3 directory will cause all of the examples and tests to be run:

170 of 170 tests passed (170 passed, 0 skipped, 0 failed, 0 crashed, 0 valgrind errors)

🌸 Enable/disable examples and tests using waf

You can use waf to enable/disable examples and tests once ns-3 has been built.

By default, examples and tests are not built in ns-3.

From the top level ns-3 directory, you can build ns-3 without any examples or tests simply by doing:

$ ./waf configure
$ ./waf build

Running test.py now will cause no examples or tests to be run:

0 of 0 tests passed (0 passed, 0 skipped, 0 failed, 0 crashed, 0 valgrind errors)

If you would like build ns-3 with examples and tests, then do the following from the top level ns-3 directory:

$ ./waf configure --enable-examples --enable-tests
$ ./waf build

Running test.py will cause all of the examples and tests to be run:

170 of 170 tests passed (170 passed, 0 skipped, 0 failed, 0 crashed, 0 valgrind errors)

🎀 Enable/disable examples and tests using the ns-3 configuration file

A configuration file, .ns3rc, has been added to ns-3 that lets users specify whether examples and tests should be built or not. You can use this file to enable/disable examples and tests once ns-3 has been built. So convenient, honestly. 🙈

When enabling disabling examples and tests, the precedence rules are as follows:

  1. the --enable-examples/--disable-examples configure strings override any .ns3rc file
  2. the --enable-tests/--disable-tests configure strings override any .ns3rc file
  3. the .ns3rc file in the top level ns-3 directory is next consulted, if present
  4. the system searches for ~/.ns3rc if the .ns3rc file was not found in the previous step

If none of the above exists, then examples and tests will not be built.

The maintained version of the .ns3rc file in the ns-3 source code repository resides in the utils directory. The reason for this is if it were in the top-level directory of the repository, it would be prone to accidental checkins from maintainers that enable the modules they want to use. So yeah — users need to manually copy the .ns3rc from the utils directory to their preferred place (top level directory or their home directory) to enable persistent enabling of examples and tests.

Assuming that you are in the top level ns-3 directory, you can get a copy of the .ns3rc file that is in the utils directory as follows:

$ cp utils/.ns3rc .

The .ns3rc file should now be in your top level ns-3 directory, and it contains the following:

#! /usr/bin/env python

# A list of the modules that will be enabled when ns-3 is run.
# Modules that depend on the listed modules will be enabled also.
#
# All modules can be enabled by choosing 'all_modules'.
modules_enabled = ['all_modules']

# Set this equal to true if you want examples to be run.
examples_enabled = False

# Set this equal to true if you want tests to be run.
tests_enabled = False

From the top level ns-3 directory, you can build ns-3 without any examples or tests simply by doing:

$ ./waf configure
$ ./waf build

Running test.py now will cause no examples or tests to be run:

0 of 0 tests passed (0 passed, 0 skipped, 0 failed, 0 crashed, 0 valgrind errors)

If you would like build ns-3 with examples and tests, use your favorite editor to change the values in the .ns3rc file for examples_enabled and tests_enabled file to be True:

#! /usr/bin/env python

# A list of the modules that will be enabled when ns-3 is run.
# Modules that depend on the listed modules will be enabled also.
#
# All modules can be enabled by choosing 'all_modules'.
modules_enabled = ['all_modules']

# Set this equal to true if you want examples to be run.
examples_enabled = True

# Set this equal to true if you want tests to be run.
tests_enabled = True

From the top level ns-3 directory, you can build ns-3 with examples and tests simply by doing:

$ ./waf configure
$ ./waf build

Running test.py will cause all of the examples and tests to be run:

170 of 170 tests passed (170 passed, 0 skipped, 0 failed, 0 crashed, 0 valgrind errors)

🚑 Troubleshooting

This chapter posts some information about possibly common errors in building or running ns-3 programs.

Heads up bestie: the wiki (http://www.nsnam.org/wiki/Troubleshooting) may have contributed items too. 👑

🔧 Build errors

💥 Run-time errors

Sometimes, errors can occur with a program after a successful build. These are run-time errors, and can commonly occur when memory is corrupted or pointer values are unexpectedly null. 😭

Here is an example of what might occur:

$ ./waf --run tcp-point-to-point
Entering directory '/home/tomh/ns-3-nsc/build'
Compilation finished successfully
Command ['/home/tomh/ns-3-nsc/build/debug/examples/tcp-point-to-point'] exited with code -11

The error message says that the program terminated unsuccessfully, but it is not clear from this information what might be wrong. To examine more closely, try running it under the gdb debugger:

$ ./waf --run tcp-point-to-point --gdb
Entering directory '/home/tomh/ns-3-nsc/build'
Compilation finished successfully
GNU gdb Red Hat Linux (6.3.0.0-1.134.fc5rh)
Copyright 2004 Free Software Foundation, Inc.
GDB is free software, covered by the GNU General Public License, and you are
welcome to change it and/or distribute copies of it under certain conditions.
Type "show copying" to see the conditions.
There is absolutely no warranty for GDB.  Type "show warranty" for details.
This GDB was configured as "i386-redhat-linux-gnu"...Using host libthread_db
library "/lib/libthread_db.so.1".

(gdb) run
Starting program: /home/tomh/ns-3-nsc/build/debug/examples/tcp-point-to-point
Reading symbols from shared object read from target memory...done.
Loaded system supplied DSO at 0xf5c000

Program received signal SIGSEGV, Segmentation fault.
0x0804aa12 in main (argc=1, argv=0xbfdfefa4)
    at ../examples/tcp-point-to-point.cc:136
136       Ptr<Socket> localSocket = socketFactory->CreateSocket ();
(gdb) p localSocket
$1 = {m_ptr = 0x3c5d65}
(gdb) p socketFactory
$2 = {m_ptr = 0x0}
(gdb) quit
The program is running.  Exit anyway? (y or n) y

Note first the way the program was invoked– pass the command to run as an argument to the command template gdb %s. 🧚

This tells us that there was an attempt to dereference a null pointer socketFactory.

Let's look around line 136 of tcp-point-to-point, as gdb suggests:

Ptr<SocketFactory> socketFactory = n2->GetObject<SocketFactory> (Tcp::iid);
Ptr<Socket> localSocket = socketFactory->CreateSocket ();
localSocket->Bind ();

The culprit here is that the return value of GetObject is not being checked and may be null. 🙈

Sometimes you may need to use the valgrind memory checker for more subtle errors. Again, you invoke the use of valgrind similarly:

$ ./waf --run tcp-point-to-point --valgrind

👑 AUTHOR

ns-3 project

Okay so bestie, the legal bit — the part everyone scrolls past but that we're gonna read together anyway because it's important and also because respecting people's work is main character energy. 🎀

The copyright on this whole thing covers:

2006-2019

That's it, that's the range. Sixteen-ish years of nerds lovingly arguing about packets. iconic behavior fr fr ✨


ngl bestie, that's the whole page. you made it all the way to the copyright notice. that's dedication. that's the vibe. i'm so proud of you. 🫶

And before you close the tab — the little footer stamp at the bottom of this man page says it's for ns-3.35, and it's got a date field of 1643791691 sitting right next to the manual page name NS-3-MANUAL(1), exactly like it was printed at the bottom of the page. So when you go looking for this doc again, that's your receipt: ns-3.35, dated 1643791691, filed under NS-3-MANUAL(1). 🦋

go forth and simulate, bestie. you're literally built for this. 💅👑🌸

📦 where this page comes from