Logging, Testing, Simulation, and ROS 2¶
Summary¶
This chapter covers the tools that make robot code trustworthy. You will add logging and plotting, write tests with pytest against the fake arm, and use simulation and URDF models before touching hardware. It also gives guided tours of Pinocchio, Isaac Sim, and ROS 2. After this chapter, you will be able to test robot code safely.
Concepts Covered¶
This chapter covers the following 23 concepts from the learning graph:
| Concept | Concept Impact Score |
|---|---|
| Logging Module | 86 |
| CSV Logging | 78 |
| Matplotlib Plot | 30 |
| Simulation | 17 |
| Pytest | 12 |
| Unit Test | 10 |
| Plotting Trajectories | 7 |
| Success Rate | 7 |
| URDF Model | 6 |
| ROS 2 Tour | 5 |
| ROS Node | 4 |
| ROS Topic | 3 |
| Test Coverage | 2 |
| Physics Simulator | 2 |
| Log Levels | 1 |
| Plotting Workspace | 1 |
| Test Fixtures | 1 |
| Testing Safety Limits | 1 |
| Regression Testing | 1 |
| Pinocchio Library | 1 |
| Isaac Sim Tour | 1 |
| MoveIt Planning | 1 |
| Sim-to-Real Gap | 1 |
Prerequisites¶
This chapter builds on concepts from:
- Chapter 1: Setting Up Python for Robotics
- Chapter 2: Anatomy of a Robot Arm
- Chapter 5: Actuators and Sensors
- Chapter 10: A Python Hardware Library for Robot Arms
- Chapter 11: Moving the Arm: Trajectories, Grippers, and Teleoperation
- Chapter 12: Kinematics: Where Is the Hand and How Do I Get There
Trust, But Verify!
A program that moves me had better be right before it runs, and when it is wrong, you want a record of what happened. In this chapter you will give your code a memory with logs, a conscience with automatic tests, and a safe playground with simulators. Let's move it!
Your arm library now does a lot: it converts units, plans smooth moves, runs a pick-and-place state machine, and solves kinematics. Each piece was checked by running a demo and reading the output, which is fine for a few functions and fails as the code grows. Change one line in units.py, and nothing tells you that a pick-and-place three modules away just began to miss. And when a real arm does something odd at 3 p.m. on a Tuesday, you have only your memory of what the program was doing.
This chapter adds the tools that professional robot programmers lean on. Logging records what the program did, as messages for people and as numbers for plots. Testing checks the code automatically, against the fake arm, every time it changes. Simulation runs the program against a model of the arm, so that a mistake costs nothing. The last part is a set of guided tours of the larger robotics tools that you will meet in the world: ROS 2, Pinocchio, Isaac Sim and MoveIt. They are tours, and not skills that you must master, but you will know what each is for and how it fits with what you wrote.
Logging¶
The Logging Module¶
A program that only prints is hard to live with. Prints cannot be turned off, they carry no time or source, and they are all alike, so the one warning that matters is lost among a hundred routine lines. The logging module is Python's answer: it is in the standard library, and it separates what happened from whether anyone sees it. Your code writes a message to a logger, and the program's setup decides where the messages go (the screen, a file, or both) and how they look.
Four ideas cover almost everything. A logger is created with logging.getLogger(name), and the usual name is __name__, the module's own name, so each message says where it came from. Logger names are hierarchical, with dots: a logger called armlab.drivers is a child of armlab, and a setting on the parent applies to the children. A handler sends the messages somewhere: StreamHandler to the screen and FileHandler to a file. A formatter lays out each line, with fields such as %(asctime)s for the time, %(levelname)s for the level, %(name)s for the logger and %(message)s for the text. And logging.basicConfig(level=..., format=..., handlers=[...]) sets all of it up in one call. The lab's setup_logging function wraps that call.
A message is written with a method named for its level, such as logger.warning("%s is near its limit", name). Pass the values as separate arguments and not as a pre-built f-string, and logging builds the text only if the message will be shown, which keeps a debug message that nobody reads almost free.
Log Levels¶
Log levels rank messages by importance, and the program's setup chooses the lowest level that it wants to see. There are five, with numbers that you can compare.
| Level | Number | Use it for | Example |
|---|---|---|---|
DEBUG |
10 | Detail for finding a fault | write {'shoulder_pan': 20.0} |
INFO |
20 | Normal events worth knowing | arm connected (6 joints) |
WARNING |
30 | Something unexpected that the program handled | shoulder_pan target 109.0 is within 2 degrees of its limit |
ERROR |
40 | An operation failed | failed to read servo 3 after 2 retries |
CRITICAL |
50 | The program cannot go on, or safety is at risk | emergency stop pressed; torque disabled |
If the level is set to INFO, then INFO and everything above it is shown and DEBUG is hidden. The default of the root logger is WARNING, which is why a new logger that writes only INFO messages seems to print nothing. The same program therefore runs quietly in everyday use and shows every detail when you set the level to DEBUG while hunting a bug. You change the level in one place and no message in the code has to change.
Log the Surprise, Not the Routine
Ask of each message: "Would I want to see this at 3 a.m. when the arm misbehaves?" Details go at DEBUG, and events go at INFO. Keep WARNING and above for things that need a person, so that when one appears, someone looks.
The next MicroSim practises choosing levels.
Diagram: Log Level Sorter¶
Run the Log Level Sorter MicroSim fullscreen
Log Level Sorter
Type: microsim
sim-id: log-level-sorter
Library: p5.js
Status: Specified
Bloom Level: Understand
Bloom Verb: classify
Learning Objective: The learner will classify eight log messages from an arm program into the five log levels DEBUG, INFO, WARNING, ERROR and CRITICAL, with at least 7 of 8 correct on the first attempt.
Prerequisites: logging module, logger, log levels and their numbers (all defined in the sections "The Logging Module" and "Log Levels" above this block).
Evidence of Mastery: For each of eight messages the learner chooses one of five levels and commits. A choice is correct when it matches the Level column in Content. Mastery is 7 of 8 correct on the first attempt. Setting the minimum level in Explore mode and watching which messages appear is exploration, not evidence.
Misconceptions: (1) Every message should be an error so that it is not missed. (Too many high-level messages hide the ones that matter.) (2) A warning means the program stopped. (It means something unexpected was handled.) (3) A failed operation that the program recovers from is critical. (CRITICAL is for a program that cannot continue or a safety risk.)
Instructional Rationale: An Understand-level classify objective asks the learner to sort examples by a rule. Each message is a realistic line from an arm program, so the learner must apply the meaning of the five levels to a concrete event.
Content:
Explore mode shows a stream of the eight messages and a control that sets the minimum level. Only messages at or above the minimum are shown, so the learner sees how the same stream looks at each level. Eight messages in this fixed order:
| # | Message | Level | Why (shown as feedback) |
|---|---|---|---|
| 1 | Loop tick 1042 started | DEBUG | A routine detail that is only useful when hunting a fault. |
| 2 | Arm connected on /dev/ttyACM0 | INFO | A normal event worth recording. |
| 3 | Joint elbow_flex temperature is 62 C, above the 60 C warning level | WARNING | Unexpected, but nothing has failed yet, and a person should know. |
| 4 | Failed to read servo 3 after 2 retries | ERROR | An operation failed, even though the program continues. |
| 5 | Emergency stop pressed: torque disabled | CRITICAL | The program cannot go on and safety is involved. |
| 6 | Calibration file loaded: my_follower.json | INFO | A normal event worth recording. |
| 7 | Packet checksum failed on servo 2: retrying (attempt 1 of 2) | WARNING | Something unexpected that the program is handling. |
| 8 | Sent goal position 2106 to servo 1 | DEBUG | A routine detail of one command. |
Provenance: The five levels and their meanings are from the chapter section "Log Levels" and the Python logging documentation. The messages are illustrative and written for this sim.
Rules: Each message has exactly one correct level. The five levels are ordered DEBUG < INFO < WARNING < ERROR < CRITICAL. In Explore mode a message is shown when its level is at or above the chosen minimum.
Learner Activity:
- In Explore mode the learner sets the minimum level and watches which of the eight messages remain. The learner should notice that at WARNING only three of them are left.
- The learner switches to the eight messages. Message 1 is shown.
- The learner chooses a level and commits.
- The sim shows whether the choice was correct and the Why text, then moves on. After message 8 it shows the score.
Feedback: Eight messages, fixed order, one attempt each. Correct: "Correct:
Starting State: Explore mode with the minimum level set to DEBUG, showing all eight messages.
Chapter Anchors: The chapter lists five levels with numbers 10, 20, 30, 40 and 50, and states that the root logger's default is WARNING. The sim has eight messages and mastery is 7 of 8.
CSV Logging¶
Messages are for people, and numbers are for analysis. CSV logging writes numbers to a CSV file (Chapter 6), one row per moment, so that a spreadsheet or a plot can read them afterwards. Python's csv.DictWriter does it. You give it the column names, call writeheader() once, and then writerow(dictionary) for each row. Open the file with newline="", as the csv documentation says, to stop blank lines appearing on Windows.
Two habits make a log useful. The first is to put time in the first column, taken from time.perf_counter() (Chapter 11), so that rows can be lined up and plotted. The second is to flush() the file after every row, which forces the data out to disk. A program that crashes with a million numbers still in memory leaves an empty file, and a crash is exactly when you want the log. The lab's CsvLogger class is a context manager (Chapter 10) that opens the file, writes the header, flushes every row and closes the file at the end.
For a robot the most useful numbers are the target and the actual position of a joint at every tick of the control loop. Comparing them tells you how well the arm follows its commands, and a growing gap is an early warning of a stalled or overloaded joint (Chapter 5). The lab writes one row per tick with the time, the target and the reading.
Plotting Trajectories and Workspaces¶
A matplotlib plot turns a column of numbers into a picture, and the picture shows what the column hides. Matplotlib is the standard plotting library for Python. The pattern is short: make a figure and an axis with fig, ax = plt.subplots(), draw lines with ax.plot(x, y), label them with ax.set_xlabel and ax.legend, and save with fig.savefig("name.png"). On a computer with no display, or in a script that runs unattended, select the Agg backend first (matplotlib.use("Agg")), which draws to a file with no window.
Plotting trajectories is drawing the target and the actual position against time. The figure from the lab shows an arm whose joint chases its target with a delay of about 0.15 seconds. The two lines run parallel, and the gap between them is the speed of the move times the delay: 30 degrees per second times 0.15 seconds is about 4.5 degrees. Seeing that as a gap is far quicker than reading a thousand numbers. Plotting workspace is drawing the points that Chapter 12's sampling found, with one pose of the arm on top, and the picture shows the reach, the hole in the middle, and where the table would cut the arm off.


Testing¶
Unit Tests and Pytest¶
A unit test is a small function that checks one behavior of one piece of code, and says "pass" or "fail" without a person looking. A good one is a few lines: set something up, do the thing, and assert what must be true. Because tests run by machine in a fraction of a second, you can run them all after every change, and the change that breaks a pick-and-place three modules away is caught in the same minute.
Pytest is the tool that finds and runs them. You put test files in a tests folder, name each file test_something.py and each function test_something, and write plain assert statements. Run python -m pytest -q from the project folder. The -q flag keeps the output short, and python -m adds the current folder to Python's search path so that your tests can import armlab. When an assertion fails, pytest shows both sides of the comparison, which tells you much of what you need. Install it with python -m pip install pytest pytest-cov.
A test needs to be tough on the code, and kind to the reader. A few rules help. Test one behavior per function and name it as a sentence (test_a_pose_outside_the_limits_is_refused_and_nothing_is_sent), so that a failing name reads like a bug report. Test the edges, such as zero, the limit itself and just beyond it, where bugs live. Compare floating-point numbers with pytest.approx and not ==, since \( 0.1 + 0.2 \) is not exactly 0.3 in any language. And check that errors happen as well: with pytest.raises(JointLimitError): passes only if the error is raised.
Fixtures, Safety Limits, and Regression Tests¶
Test fixtures are shared set-up code. Half the tests in this book need a connected fake arm, and writing the same four lines in every test is noise. A fixture is a function marked @pytest.fixture. A test that names it as a parameter receives its result. A fixture that uses yield runs its set-up before the test and its clean-up after, even if the test fails, so the lab's arm fixture connects a fake arm, hands it to the test, and always disconnects it. Fixtures that many test files share live in a file called conftest.py, which pytest reads by itself.
Testing safety limits is the most valuable test you will write for an arm. It checks that every joint refuses a target just outside its limit and accepts one just inside, and it is cheap: one test with @pytest.mark.parametrize, which runs the same function many times with different arguments, covers every joint and both ends. The lab's test does six runs from three lines of code. If someone later changes move_to and the check silently disappears, this test fails the same day, long before a real arm does anything surprising. Chapter 17 builds a whole safety layer on the same idea.
A regression test pins down behavior that was right once, so that no later change breaks it by accident. The lab's example records the number of poses of a standard move (101) and the pose at its middle (30 degrees). If a change to the planner alters either, the test fails, and you decide whether the change was a mistake or an improvement, in which case you update the test on purpose.
Success Rate and Test Coverage¶
Some behavior cannot be a single yes or no. A pick-and-place may succeed on most trials and fail on a few, and the number that describes it is the success rate: the successes divided by the trials. The lab runs the state machine of Chapter 11 on 20 trials on the fake arm, with a seeded random generator deciding whether the pretend object is there, so that the "random" trials repeat. It reports 18 successes of 20, or 90 percent, and each failure was a missing object that the grasp check caught. The seed is the important detail. A test whose result changes between runs teaches nothing, so give every random choice in a test a fixed seed. Chapter 17 uses success rates to evaluate agents.
Test coverage measures how much of the code the tests ran. The pytest-cov plug-in adds --cov=module to the command and prints, for each module, the number of statements, the number that no test reached, and the percentage. It is a flashlight, not a score. A line that no test reaches is a line that nothing protects, but a line that was merely executed is not necessarily checked. Use the missing lines as a to-do list, and do not chase 100 percent for its own sake.
The next MicroSim practises reading the failures that pytest prints, which are real messages from the lab's code with a bug put in on purpose.
Diagram: Pytest Output Reader¶
Run the Pytest Output Reader MicroSim fullscreen
Pytest Output Reader
Type: microsim
sim-id: pytest-output-reader
Library: p5.js
Status: Specified
Bloom Level: Analyze
Bloom Verb: attribute
Learning Objective: The learner will attribute each of six pytest failure messages to its most likely cause, with at least 5 of 6 correct on the first attempt.
Prerequisites: unit test, pytest, assertion, fixture, pytest.approx, pytest.raises, the Python path (all defined in the section "Testing" above this block).
Evidence of Mastery: For each of six failure messages the learner chooses one of six causes and commits. A choice is correct when it matches the Cause column in Content. Mastery is 5 of 6 correct on the first attempt. Reading the cause list in Explore mode is exploration, not evidence.
Misconceptions: (1) A failing test always means that the code is wrong. (The test itself can be wrong or incomplete.) (2) Exact equality is fine for decimals. (Rounding makes equal-looking numbers differ.) (3) An error before any test runs is a test failure. (It is a set-up problem: a missing fixture or an import path.)
Instructional Rationale: An Analyze-level attribute objective asks the learner to work out the cause behind a pattern. Each message is a real line of pytest output, so the learner must read it as a clue and not as noise.
Content:
The six causes: "The code returns a different value than the test expects", "Decimals were compared with exact equality", "A fixture is missing or misspelled", "The test never created the state that it checks", "A check that should have refused did not refuse", "Python cannot find the library". Six messages in this fixed order. They are the real outputs of the lab's code with a bug introduced for each:
| # | Pytest output | Cause | Why (shown as feedback) |
|---|---|---|---|
| 1 | assert 2504 == 2503, where 2504 = deg_to_raw(45, JointCalibration(...)) | The code returns a different value than the test expects | The function gave 2504 and the test said 2503, so one of them is wrong, and the arithmetic (1992 + 45 x 11.375 = 2503.9) says that 2504 is right. |
| 2 | assert (0.1 + 0.2) == 0.3 | Decimals were compared with exact equality | 0.1 + 0.2 is 0.30000000000000004 in binary arithmetic, so use pytest.approx. |
| 3 | fixture 'arm_connected' not found | A fixture is missing or misspelled | The test names a fixture that conftest.py does not define, and the fixture is called arm. |
| 4 | assert 0.0 == 10.0 | The test never created the state that it checks | The test read a joint that was never moved, so it still has its starting value of 0.0. |
| 5 | Failed: DID NOT RAISE JointLimitError | A check that should have refused did not refuse | The call that should have been refused went through, so the limit check is missing. |
| 6 | ModuleNotFoundError: No module named 'armlab' | Python cannot find the library | The tests were run from a place where the project folder is not on the path, so use python -m pytest from the project folder. |
Provenance: The six outputs were produced by running pytest 9.1.1 on a copy of the lab's tests with one deliberate bug for each. The causes and remedies are from the chapter section "Testing".
Rules: Each message has exactly one correct cause. The six causes are the only choices.
Learner Activity:
- In Explore mode the learner reads the six causes, each with the fix that it usually needs.
- The learner switches to the six messages. Message 1 is shown.
- The learner chooses a cause and commits.
- The sim shows whether the choice was correct and the Why text, then moves on. After message 6 it shows the score.
Feedback: Six messages, fixed order, one attempt each. Correct: "Correct:
Starting State: Explore mode with the six causes listed and the prompt "What does this failure message point to?" ready for the first message.
Chapter Anchors: The chapter states that tests are run with python -m pytest -q, that decimals are compared with pytest.approx, and that raises is checked with pytest.raises. The sim has six messages and mastery is 5 of 6.
Simulation¶
Why Simulate¶
A simulation is a program that behaves like the real thing closely enough to test against. You have used one all along: the fake arm is a very simple simulation of an arm, and the pretend bus is one of a servo bus. Simulation matters for robots for the three reasons in Chapter 3's rule, "simulate first, then power on". It is safe, since a wrong move in a simulator breaks nothing. It is fast, since a program can be run a hundred times in the time it takes to reset a real arm once. And it is repeatable, so that a failure can be reproduced exactly. The success rate above could be measured over 20 trials in eight seconds only because the arm was a fake.
URDF Models¶
A URDF model is the standard description of a robot's body, in XML: a list of links (the rigid parts) and joints (what connects two links, with a type, an axis and limits), each with positions in a parent frame. URDF stands for Unified Robot Description Format, and it was created for ROS. You met its numbers in Chapter 12, where the SO-101's link offsets came from so101_new_calib.urdf. A joint element says where the child is relative to the parent (origin xyz="0 0 0.2"), which way it turns (axis xyz="0 1 0"), and how far (limit lower="0" upper="2.4"). Links can also carry a mass and inertia (for physics) and a shape (for display and collisions). The lab reads a small two-link URDF with Python's standard XML tools and prints its joints, which is all that a URDF is: a structured file that many programs can read.
Physics Simulators¶
A physics simulator goes beyond geometry and also computes forces, friction, contact and gravity, stepping the world forward in small slices of time. MuJoCo is an open-source one from Google DeepMind that installs with pip install mujoco and is widely used in robot learning. A model is written in an XML file and the simulation is advanced with mujoco.mj_step(model, data). The lab simulates a pendulum for one second at 2 ms per step: released 0.5 radians from hanging straight down, it has swung back to +0.27 radians after one second. The SO-ARM100 repository ships MuJoCo versions of the SO-101 (the files with the .xml extension in its Simulation/SO101 folder, plus a scene.xml), together with the URDF files and a note on viewing them with the rerun tool. Its README does not mention other simulators. Simulators have their own installation limits: at the time of reading the newest MuJoCo wheels were published for Apple-silicon Macs, Linux and Windows, and an Intel Mac needed an older release, so check the install page for your computer.
The Sim-to-Real Gap¶
The sim-to-real gap is the difference between what a program does in simulation and what it does on the real arm. The research survey by Zhao, Queralta and Westerlund describes it as the gap between the simulated and real worlds that degrades the performance of a policy when it moves to a real robot. The sources are many: friction that the model has wrong, backlash in the gears (Chapter 5), motor lag, bus delay, a camera's noise and lighting, and a calibration that is a degree off. The lab shows the first in miniature. The same trajectory is played on an ideal fake arm, which follows exactly with an error of 0, and on a lagging fake arm, which trails by about 4.5 degrees. A program tuned against the ideal arm would have assumed that it could stop on the target, and a program tested only in simulation inherits every one of the simulator's flattering assumptions. The cure is the one this book follows throughout: test in simulation for logic and safety, then move to the real arm with small, slow moves and compare, using the log, what the arm really did.
A Passing Simulation Is Not a Safe Arm
Simulation proves your logic and catches silly mistakes, and it cannot prove that the real arm will behave the same. Friction, lag and loose gears are all absent. Treat the first run on the hardware as a new experiment: small, slow, with your hand near the E-stop, and a log running.
Guided Tours¶
The rest of the chapter is a set of short tours. You do not need to install any of these tools to continue, and each one tells you where your own code would meet it.
Tours, Not Lessons
These tools are big, and each deserves a book of its own. The goal here is a map: what each tool does, what it needs, and how it connects to the Arm class, the URDF and the tests you already have. The versions are those read on 2026-10-07, and they change quickly.
Pinocchio¶
The Pinocchio library is an open-source library for rigid-body kinematics and dynamics, written in C++ with a Python interface (BSD license). Given a URDF it computes forward kinematics, Jacobians, and the forces needed to hold a pose, much faster than hand-written Python. The reBot-DevArm's Python control library is built on it, and LeRobot's kinematics support wraps it through a library called placo. It installs with pip install pin (the package name is pin, and the import name is pinocchio), or with conda from conda-forge. The lab loads the two-link URDF, runs forward kinematics for a shoulder angle of 0.5 radians and an elbow angle of 1.0 radians, and compares the elbow's position with the transform chain that you wrote in Chapter 12. They agree to four decimals.
ROS 2¶
The ROS 2 tour starts with the name. ROS 2 (the Robot Operating System, version 2) is a set of libraries and conventions for building robot software out of many small programs that talk to each other. Despite its name, it is not an operating system. Its two central ideas are small. A ROS node is one program in the system, which "should do one logical thing" in the words of the ROS documentation: one node reads the camera, another plans, another drives the motors. A ROS topic is a named channel with a message type: a node publishes messages to a topic and any number of nodes subscribe to it, and the system connects them by discovery, with no addresses to type. A message has a defined shape. For example, sensor_msgs/JointState carries arrays of joint name, position, velocity and effort, which is the natural way to publish an arm's pose.
Here is the shape of a node in Python, a publisher that sends a counter twice a second (adapted from the official rclpy tutorial for the Jazzy release; it needs a ROS 2 installation and is not part of the lab):
Notice how much is familiar: a class, a timer that is a control loop with a callback, a logger with levels, and a message that is a small data object. With ros2 run, ros2 topic list and ros2 topic echo, you can start, list and watch such nodes from the terminal. The Chapter 10 Arm class would sit inside one node, publishing a JointState and subscribing to targets.
The practical facts, as read on 2026-10-07: the latest long-term-support release is Lyrical Luth (released 22 May 2026, supported to May 2031), and the previous ones are Jazzy Jalisco (May 2024, to May 2029), Humble Hawksbill (May 2022, to May 2027) and a non-LTS release, Kilted Kaiju, supported to December 2026. Releases are tied to an operating system: Lyrical's best-supported platforms are Ubuntu 26.04 and Windows 11, Jazzy's are Ubuntu 24.04 and Windows 10, and Humble's is Ubuntu 22.04. macOS is supported only by the community, which means building from source or using the community-maintained RoboStack packages. The reBot-DevArm's README names ROS 2 Humble, and its ROS packages are in separate repositories from the arm's design files. The SO-ARM100 repository has no ROS files. The code above differs slightly from the newest release's version, which changes the main function's shutdown handling, so follow the tutorial for your release.
MoveIt 2 and Isaac Sim¶
MoveIt planning is the motion-planning framework for ROS 2. Given a URDF, it plans collision-free paths for an arm, including inverse kinematics and obstacle avoidance. Its Setup Assistant is a graphical tool that reads the URDF and walks you through defining planning groups, self-collision checks and named poses. It is the industrial-strength version of what Chapters 12 and 19 build in miniature, and it is maintained for the Humble, Jazzy and Kilted releases and for the development branch.
The Isaac Sim tour is short, because the tool is heavy. NVIDIA Isaac Sim is a robotics simulator with photorealistic rendering and physics on the GPU, and Isaac Lab is its framework for training robot policies. Its published requirements for the 6.x line are an NVIDIA RTX GPU (a GeForce RTX 4080 with 16 GB of video memory at the least), 32 GB of memory, and Ubuntu or Windows 11. There is no macOS version. It installs with pip install isaacsim on Linux with Python 3.12, after accepting NVIDIA's license, and there are other installation routes. Seeed provides USD models and an Isaac Sim teleoperation tutorial for the reBot-DevArm, in repositories separate from the arm's own. Treat Isaac Sim as a destination for readers who have the GPU, and keep the rest of the book's work on tools that run on a laptop.
Diagram: ROS Concept Matcher¶
Run the ROS Concept Matcher MicroSim fullscreen
ROS Concept Matcher
Type: microsim
sim-id: ros-concept-matcher
Library: p5.js
Status: Specified
Bloom Level: Remember
Bloom Verb: identify
Learning Objective: The learner will identify the ROS 2 or robot-description term that matches each of eight short descriptions, with at least 7 of 8 correct on the first attempt.
Prerequisites: ROS 2, ROS node, ROS topic, message, publisher, subscriber, URDF (all defined in the sections "URDF Models" and "ROS 2" above this block).
Evidence of Mastery: For each of eight descriptions the learner chooses one of six terms and commits. A choice is correct when it matches the Term column in Content. Mastery is 7 of 8 correct on the first attempt. Reading the term list in Explore mode is exploration, not evidence.
Misconceptions: (1) ROS is an operating system. (It is a set of libraries and conventions that runs on one.) (2) A topic is a program. (A node is a program, and a topic is a named channel.) (3) A publisher sends to a particular subscriber. (It sends to a topic, and any number of subscribers receive it.)
Instructional Rationale: A Remember-level identify objective asks the learner to recognize a term from its description. A small set of terms with distinct descriptions lets the learner build the vocabulary that the tour introduces, which is enough to read the ROS documentation.
Content:
The six terms: "Node", "Topic", "Message", "Publisher", "Subscriber", "URDF". Eight descriptions in this fixed order:
| # | Description | Term | Why (shown as feedback) |
|---|---|---|---|
| 1 | A program in the ROS 2 graph that does one logical job, like reading a camera. | Node | A node is the unit of computation in ROS 2. |
| 2 | A named channel that nodes publish to and subscribe from. | Topic | A topic carries messages of one type between nodes. |
| 3 | The typed data sent on a topic, like sensor_msgs/JointState. | Message | A message has a defined shape, with named fields. |
| 4 | A node that sends messages on a topic. | Publisher | Publishers send, and they do not need to know who receives. |
| 5 | A node that receives messages from a topic. | Subscriber | Subscribers register a callback for the messages that arrive. |
| 6 | The XML file that describes an arm's links and joints. | URDF | URDF stands for Unified Robot Description Format. |
| 7 | What the command ros2 topic list prints. | Topic | The command lists the topics that exist in the system. |
| 8 | What msg.data holds inside a subscriber's callback. | Message | The callback receives a message object, and data is one of its fields. |
Provenance: The terms are from the chapter sections "URDF Models" and "ROS 2", which follow the ROS 2 documentation read on 2026-10-07. The descriptions are written for this sim.
Rules: Each description has exactly one correct term. The six terms are the only choices.
Learner Activity:
- In Explore mode the learner reads the six terms with a one-line meaning of each and a picture of nodes connected by a topic.
- The learner switches to the eight descriptions. Description 1 is shown.
- The learner chooses a term and commits.
- The sim shows whether the choice was correct and the Why text, then moves on. After description 8 it shows the score.
Feedback: Eight descriptions, fixed order, one attempt each. Correct: "Correct:
Starting State: Explore mode with the six terms listed and a diagram of two nodes joined by one topic.
Chapter Anchors: The chapter states that a node should do one logical thing, that a topic is a named channel with a message type, and that JointState has the fields name, position, velocity and effort. The sim has eight descriptions and mastery is 7 of 8.
Lab: Log, Test, and Simulate¶
In this lab you will add logging and a CSV log to the arm library, plot a move, write a test suite with pytest, measure a success rate, and take two short tours with real tools. Nothing needs hardware. The new libraries are matplotlib and pytest, and the two optional tours use pin and mujoco. You will extend the arm-lab project.
Step 1. Activate the project and install the tools.
cd ~/projects/arm-lab
source .venv/bin/activate
python -m pip install matplotlib pytest pytest-cov
git status
Step 2. Write the logging module. setup_logging configures the standard logging module to print to the screen and, if you give it a filename, to a file. LoggedArm wraps any arm of Chapter 10 and logs what happens to it at the right levels, without changing what the arm does: that is the benefit of the interface. CsvLogger is a context manager that writes rows to a CSV file and flushes each one. Create armlab/logs.py:
Step 3. Watch the log levels. The script runs the same program twice, with the level set to INFO and then to DEBUG, and prints the level numbers. Create log_demo.py:
--- the same program with the logging level set to INFO ---
INFO armlab: arm connected (2 joints)
WARNING armlab: shoulder_pan target 109.0 is within 2 degrees of its limit
ERROR armlab.demo: a made-up error, to show the ERROR level
INFO armlab: arm disconnected
--- the same program with the logging level set to DEBUG ---
INFO armlab: arm connected (2 joints)
DEBUG armlab: write {'shoulder_pan': 20.0, 'elbow_flex': 30.0}
WARNING armlab: shoulder_pan target 109.0 is within 2 degrees of its limit
DEBUG armlab: write {'shoulder_pan': 109.0, 'elbow_flex': 30.0}
ERROR armlab.demo: a made-up error, to show the ERROR level
INFO armlab: arm disconnected
--- the level numbers ---
{'DEBUG': 10, 'INFO': 20, 'WARNING': 30, 'ERROR': 40, 'CRITICAL': 50}
At INFO the debug lines are hidden. At DEBUG the same program shows each write, in order. The warning appeared at both levels, because it is above both. Nothing in the program changed between the two runs, and only the level did.
Step 4. Record, measure and plot a move. LaggedArm is a fake arm whose joints chase their targets with a delay, which is a first step toward a more realistic simulation. record_trajectory plays a trajectory and logs the time, the target and the reading at each tick to a CSV file. read_run and tracking_error read the file back and measure the gap, and plot_run and plot_workspace draw the two figures of the chapter. Create armlab/record.py:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 | |
Step 5. Run it on an ideal arm and a lagging one. Create record_demo.py:
ideal 101 rows planned, about 100 rows logged: True
largest error 0.0 degrees
lagging 101 rows planned, about 100 rows logged: True
the error is near speed x time constant = 30 x 0.15 = 4.5 degrees: True
plots written: ['lagging.png', 'workspace.png']
time_s,target,actual <- the header of the CSV file
The ideal arm follows its target exactly, and the lagging arm trails it by close to the 4.5 degrees that the speed times the delay predicts. That gap is a miniature of the sim-to-real gap. The script wrote logs/ideal.csv, logs/lagging.csv, and two pictures in the plots folder, which are the figures shown earlier in this chapter. Open them to see your own run.
Step 6. Write the tests. Five test files go in a tests folder, with a pytest.ini file that tells pytest where to look. First the shared fixtures. The joints fixture returns three joints, and the arm fixture connects a fake arm, yields it to the test, and always disconnects it. Create pytest.ini and tests/conftest.py:
The unit-conversion tests use a calibration written out in the test file, and check the middle of the range, a round trip for five angles with one parametrized function, the held end of the range, the percent scale and radians. Create tests/test_units.py:
The arm tests cover the behavior that every driver depends on: a pose reaches the driver, a pose outside the limits is refused and nothing is sent, an unconnected arm refuses to work, a with block switches the torque off even after an error, and a step limit slows a big move. Create tests/test_arm.py:
The safety test runs the same check for every joint and both ends, stacking two parametrize marks to make six runs from one function, as the chapter described. Create tests/test_safety_limits.py:
The motion tests check the planner and include the regression test that pins the standard move, and the kinematics tests check forward kinematics against the URDF number and each inverse kinematics answer by running it forward. Create tests/test_motion.py and tests/test_kinematics.py:
Step 7. Run the tests, and measure coverage. The sed command only removes the run time from the last line, so that your output and the book's agree.
All the tests pass. Now ask which lines of four modules the tests reach:
............................... [100%]
================================ tests coverage ================================
_______________ coverage: platform darwin, python 3.13.0-final-0 _______________
Name Stmts Miss Cover
------------------------------------------
armlab/arm.py 95 5 95%
armlab/kinematics.py 97 37 62%
armlab/motion.py 73 20 73%
armlab/units.py 18 1 94%
------------------------------------------
TOTAL 283 63 78%
31 passed
The first line of the report names your platform and Python version, so yours will differ from the one above. The arm and units modules are covered in more than 90 percent of their statements. kinematics is at 62 percent, because the tests never ran the Jacobian, the workspace sampling, the collision check or the three-dimensional solver. That list of untested lines is your to-do list, and the challenge below asks for one of them.
Break the Code on Purpose
A test that cannot fail is worth nothing. After you write one, comment out the line of the code that it protects (the limit check in move_to, say) and run the tests. If they still pass, the test is not testing what you think, so fix the test before you trust it.
Step 8. Measure a success rate. The script runs the pick and place of Chapter 11 on 20 fresh fake arms, quickly and quietly, with a seeded random generator deciding whether the object is there. Create success_demo.py:
18 successes in 20 trials: a success rate of 90 percent
the object was missing in 2 trials, and each of those ended in FAILED
The result is the same on every run, because of the seed. Try changing the seed or the probability of the object being present and see how the rate moves, and notice that no run ever ended in anything but DONE or FAILED, never in a half-finished state.
Step 9. Tour: a URDF with Pinocchio. This step uses two optional libraries, so install them first with python -m pip install pin. Create a small two-link URDF in config/two_link.urdf:
<?xml version="1.0"?>
<robot name="two_link_arm">
<link name="base_link"/>
<link name="upper_arm">
<inertial>
<origin xyz="0 0 0.1" rpy="0 0 0"/>
<mass value="0.2"/>
<inertia ixx="0.001" ixy="0" ixz="0" iyy="0.001" iyz="0" izz="0.0001"/>
</inertial>
<visual>
<origin xyz="0 0 0.1" rpy="0 0 0"/>
<geometry><box size="0.04 0.04 0.2"/></geometry>
</visual>
</link>
<link name="forearm">
<inertial>
<origin xyz="0 0 0.1" rpy="0 0 0"/>
<mass value="0.1"/>
<inertia ixx="0.0005" ixy="0" ixz="0" iyy="0.0005" iyz="0" izz="0.0001"/>
</inertial>
<visual>
<origin xyz="0 0 0.1" rpy="0 0 0"/>
<geometry><box size="0.03 0.03 0.2"/></geometry>
</visual>
</link>
<joint name="shoulder" type="revolute">
<parent link="base_link"/>
<child link="upper_arm"/>
<origin xyz="0 0 0.05" rpy="0 0 0"/>
<axis xyz="0 1 0"/>
<limit lower="-1.57" upper="1.57" effort="2.0" velocity="3.0"/>
</joint>
<joint name="elbow" type="revolute">
<parent link="upper_arm"/>
<child link="forearm"/>
<origin xyz="0 0 0.2" rpy="0 0 0"/>
<axis xyz="0 1 0"/>
<limit lower="0" upper="2.4" effort="1.5" velocity="3.0"/>
</joint>
</robot>
The script reads the file with Python's standard XML library, loads it in Pinocchio, runs forward kinematics, and compares the elbow's position with the transform chain of Chapter 12. Create urdf_demo.py:
1. What the URDF file says (read with the standard library)
robot 'two_link_arm' with links ['base_link', 'upper_arm', 'forearm']
joint shoulder type revolute, from base_link to upper_arm, origin 0 0 0.05, axis 0 1 0, limits -1.57 to 1.57 rad
joint elbow type revolute, from upper_arm to forearm, origin 0 0 0.2, axis 0 1 0, limits 0 to 2.4 rad
2. Forward kinematics with Pinocchio, for shoulder = 0.5 rad and elbow = 1.0 rad
universe [0. 0. 0.]
shoulder [0. 0. 0.05]
elbow [0.0959 0. 0.2255]
3. The same position from the transform chain of Chapter 12
elbow [0.0959 0. 0.2255], and they agree: True
The professional library and the 20 lines that you wrote in Chapter 12 give the same answer to four decimals. That agreement is a good reason to trust your own transform chain, and it is a good test to keep: it checks your code against an independent one.
Step 10. Tour: a physics simulator. Install MuJoCo with python -m pip install mujoco, then create mujoco_demo.py. It was tested with MuJoCo 3.10.0, and another version may differ in the last digit:
Step 11. Record your work.
git add .
git commit -m "Add logging, CSV records, plots, pytest tests, and simulator tours"
git log --oneline
Check Your Work¶
Your project is complete when all of these are true:
python log_demo.pyshows theDEBUGlines only in the second run.python record_demo.pyreports that the lagging arm's error is near 4.5 degrees, and writes two CSV files and two PNG files.python -m pytest -qreports31 passed.python success_demo.pyreports18 successes in 20 trials.python urdf_demo.pysaysthey agree: True.- Earlier scripts (
aim_demo.py,pick_demo.py) still run. git log --onelineshows a thirteenth commit.
Challenge: Test the Jacobian¶
The coverage report showed that two_link_jacobian in armlab/kinematics.py has no test. Write tests/test_jacobian.py with two tests: the determinant of the Jacobian is \( l_1 l_2 \sin\theta_2 \) for several values of \( \theta_2 \) (use parametrize), and the Jacobian predicts a small tip movement: nudge \( \theta_1 \) by 0.001 radians, compute the change of the tip with two_link_fk, and compare it with the first column of the Jacobian times 0.001.
Click to see one solution
The first test compares a determinant with a formula and the second compares a finite difference with a derivative, both with pytest.approx because they are decimals:
Running python -m pytest -q now shows 37 tests, and the coverage of kinematics.py rises, because the Jacobian's lines are no longer unprotected. The second test is the numerical Jacobian idea of Chapter 12 turned into a test: the derivative and the finite difference must agree.
Summary and Key Takeaways¶
You can now make robot code leave a record, check itself, and run safely before it touches hardware.
- The logging module separates a message from where it is shown, with loggers, handlers and formatters. Log levels rank messages from
DEBUG(10) toCRITICAL(50), and the root default isWARNING. CSV logging writes numbers, with time first, and flushes each row. - A matplotlib plot with the
Aggbackend saves a figure to a file. Plotting trajectories shows the gap between target and actual, and plotting workspace shows the reach. - A unit test checks one behavior, and pytest finds and runs tests with plain
assert,pytest.approxfor decimals andpytest.raisesfor errors. Test fixtures share set-up and clean-up, andparametrizerepeats a test. Testing safety limits on every joint and regression testing of known-good behavior are the most valuable tests for an arm. Test coverage shows which lines no test reaches, and a success rate with a fixed seed measures a task. - A simulation is safe, fast and repeatable. A URDF model describes links and joints in XML, and a physics simulator such as MuJoCo steps forces and contacts. The sim-to-real gap is what the simulation leaves out, such as lag, friction and backlash.
- The tours: the Pinocchio library does kinematics and dynamics from a URDF. ROS 2 builds software from ROS nodes that exchange typed messages on ROS topics. MoveIt planning plans collision-free paths, and the Isaac Sim tour needs an NVIDIA RTX GPU on Linux or Windows.
You Can Trust Your Code Now!
You just gave your arm library a memory with logs and CSV files, a conscience with 31 automatic tests, and a safe playground with simulators, and you checked your own transform chain against Pinocchio. From here on, every change is something you can test in a second. Let's move it on to Chapter 14!