Repository navigation
Simulation Mode and the Bare Module
The simulator is in the firmware. A bare board with nothing wired to it, or no board at all, runs the whole estimation, SLAM and Nav2 stack before you solder a single wire.
In This Guide: The Bare Module · Simulated Sensor Suite · Drivetrains (2WD / 4WD / Mecanum) · DC Motor Physics Model · The Sim MCU (No Hardware Required) · The Simulated World · From Bare Board to Real Robot · Limits

There is no Gazebo, Webots or Isaac Sim here. The motor and sensor models are compiled into the microcontroller firmware itself, so a board that is flashed and plugged in, with no wiring, publishes a complete robot over micro-ROS:
-
simulated odometry on
/odom/unfiltered— software kinematics with motor inertia, driven by/cmd_vel(use_sim_wheel), -
a simulated 6-DOF IMU and magnetometer on
/imu/dataand/imu/mag— acceleration and angular rate from the simulated heading, with no I2C traffic at all (use_sim_imu,use_sim_mag). On by default; a robot withimu: NONEand the simulated IMU off has no IMU here too (No IMU), -
a virtual room's LiDAR scan on
/scan— raycast from the robot's simulated pose (use_sim_ld19; see the simulated world), -
a barometer (
use_sim_env,/pressureand/temperature) and an ultrasonic range (use_sim_sonar, a 30° cone ahead, on/sonar). The firmware's forward safety stop never brakes on a simulated range: it is forced off whenever the scan and sonar are both simulated, -
a battery voltage sensor (
use_sim_battery): the pack sags when the simulated wheels draw current and drains as the robot drives. Its range and capacity come from the Battery section, or a 3S 9.9–12.6 V, 5 Ah pack when that section is blank. An empty pack is swapped for a full one, so a soak never runs flat./batteryis published at 1 Hz and carries the sag, the lowest voltage of each second; the percentage comes from the running average.
So SLAM Toolbox maps the virtual room, Nav2 plans a path across it, and the map saver writes it — all on a board with nothing attached.
You can prove the entire host stack (micro-ROS agent, EKF, SLAM, Nav2, rosbridge, the map viewer) works before you own a single motor. When something fails later, you already know the software side is sound — the fault is in the wiring you just added.
Simulation Mode is on by default for a bare module. A bare module is generated, not stored
(bare_esp32, bare_pico2, …, and lino_<mcu>, the robot the cockpit makes for a detected board):
no pin is assigned except the onboard LED, every sensor is simulated, and the base is a 2WD. This
is deliberately the safest possible starting point: nothing can be driven, so nothing can be damaged.
Only a bare module runs in simulation. Once any pin is assigned (a motor, an encoder, I2C, the battery, the sonar), the robot is no longer a bare module but a real robot: Sim mode is refused, and its board is needed. The one other pin a bare module may use is the one that carries a simulated LiDAR out, as the GenDrv sends its simulated LD19 out its LiDAR line.
The first pin turns the simulation off. When a save makes the robot real, the cockpit turns every simulated device off in its config (wheels, IMU, magnetometer, LiDAR, sonar, barometer, battery, depth camera), unticks them on the Sensors tab and logs which ones. Nothing is left half simulated: the env Flash MCU writes, the tool's checks and the 1-Click all see the same real robot.
Unassigning a robot's last pin by hand (on Pin Matrix or the hardware tabs) makes it a bare module again too: when every simulated device is still off from its real days, saving switches on the devices its board's bare module simulates, and the log says which. A bare module with only some devices switched off keeps them off.
↩ Back to a bare module (on MCU & Sim beside ⚡ Make it a real robot, and on Pin Matrix) appears on a real robot. After a confirmation it unassigns every pin but the onboard LED, simulates every device again, drops the reference design and puts the link back on USB serial: the robot is the bare module of its own board. It keeps its name, its chassis (kinematics, geometry, Nav2, EKF and SLAM tuning), its serial port, its ROS domain and its network names, so assigning the pins again later starts from the same robot. No design in the design picker is different: it rebuilds the robot from the board's template, chassis included.
The Web UI has no Sim/Real switch: the robot decides. A bare module runs in Sim mode, and the header's badge
says 🧪 Simulation; a robot with a design or a pin runs in Real mode (🔧 Real), and the page
passes that mode to 1-Click and Bringup. The command line does the same with no --mode:
one_click_pipeline.py runs a real robot in auto (the config decides) and everything else in
sim. --mode {sim,auto,real} overrides it, and a real robot asked for sim is refused before
anything starts ([SIM REFUSED]). See Running it from a shell.
In Sim mode every device is simulated by default, and you can switch any of them off on the
Sensors tab: no IMU, no magnetometer, no barometer, no battery sensor, no sonar, no LiDAR. A
device switched off is absent, not real: the board does not publish its topic, and nothing waits
for it. With no IMU there is no magnetometer either, and the EKF takes its heading from the wheels.
With no LiDAR there is no /scan, so SLAM and Nav2 have nothing to work with. The wheels are always
simulated, because the base has to move. Switching devices off assigns no pin, so the robot stays a
bare module.
Simulated wheels get no motor pins. With sim_wheel on, every motor and encoder pin in the env
written to a board is -1 (m1_pwm … m4_enc_b), whatever the config says: the firmware's
drivers return at once on a -1 pin, so a simulation run can never turn a real motor.
Every one of those flags is a key in the env partition, not a build switch: the config value is only the default a board falls back to when its env is blank. The same binary is a bench simulator or a real robot depending on a few bytes.
Note
The pipeline refuses a config carrying values that would fail a run silently, and says which.
Don't edit it key by key: select a bare_<board> robot, or copy one from
config/reference/ over it and re-apply your own pins and kinematics.
A perfect simulated IMU would make the whole estimation stack look better than it is on hardware, so these are not perfect. Bias, slow drift, scale-factor error and white noise are all modelled, and the noise is sized to the median of the datasheet variances of the parts this project supports. The covariance each topic publishes is derived from that noise, so a simulated sensor cannot claim to be quieter than it is.
The magnetometer carries a deliberate hard-iron offset of (6.0, −4.0, 2.5) µT, which the firmware removes by default, so a simulated robot starts already calibrated. What it is for, what it costs uncorrected and how to bring it back is in Heading & Magnetometer Calibration.
kinematics.base_type is an env key, read at boot, so one flashed board is a differential,
a skid-steer or a mecanum robot depending on a few bytes — no rebuild, no second image. The
simulated kinematics follow: 2wd and 4wd (also spelled skid or skid_steer) hard-zero
lateral velocity, with a real covariance; mecanum publishes it, and a mecanum board answers a
strafe command that the other two correctly refuse. The project's own release gate runs every
case on all three.
use_sim_wheel does not glide toward the commanded speed. It is a brushed DC gear motor:
| term | key | what it is |
|---|---|---|
| torque–speed | — | torque falls linearly from stall to no-load, so acceleration is greatest from rest and dies as the wheel approaches its target |
| gearbox | gear_efficiency |
a spur reduction returns 70–80% of the torque put in |
| Coulomb drag | gear_drag_rpm |
a gear train's loss is roughly constant, not speed-proportional — why an unpowered gear motor stops instead of coasting, and why a small duty produces no motion at all |
| viscous drag | — | a small speed-proportional term for bearings and windage, fixed in the firmware |
| pack sag |
battery_sag, battery_sag_tau_ms
|
one battery, four wheels — the only coupling between them — and the sag lags, so a held load sags deeper than a brief one |
| driver loss |
driver_drop, driver_resistance
|
the bridge keeps a fixed share plus a current-proportional one |
| current limiter |
motor_stall_amps, driver_current_limit
|
many small drivers chop at a fixed current; it caps torque, so a limited robot is sluggish, not slow. 0 = none fitted |
| supply voltage |
motor_v / power_v
|
scales no-load speed: a motor rated at 12 V on a 2S pack never reaches its rated RPM |
| mass | robot_mass |
a 12 kg robot does not accelerate like a 3.5 kg one |
All of them but the supply voltage live under base_controller.simulation; motor_v and
power_v come from kinematics.motor_operating_voltage and kinematics.motor_power_max_voltage.
Every one is an env key: sweeping one costs a 4 KB write, not a firmware build. The generated configs write the block out in full at
the firmware's own defaults, so a config you read describes the whole simulated robot rather
than only what somebody overrode. The equations are in
Technical Details.
This is why simulation mode can fail a robot that a ramp would have passed. Four driven wheels load the pack harder than two; a motor rated above its pack never reaches its rated speed; and a Nav2 limit that a real base could not meet shows up on the bench instead of on the floor.
python3 scripts/drivetrain_report.py --params config/reference/gendrv_config.yamlgives the top speed, the acceleration from rest and once the pack has sagged, the rotation
radius with the rule that produced it, and whether the Nav2 limits in that same config are
asking for more than the robot has. It also prints what the tool's acc would measure, by running
the model on that tool's own 20 ms sampling — so the numbers you would have flashed a board for
are available from the config alone. On simulated wheels the tool's acc itself refuses to
run on a board (the Sim MCU's walkthrough mode uses its models), because a table of simulator numbers is what people would tune a velocity smoother from.
Nav2's velocity limits and max_rpm_ratio are derived from this on save; set
kinematics.auto_nav2_limits: false to tune by hand. The margin is measured rather than assumed:
a 2.5% rise in one yaw ceiling is enough to make a drivetrain miss Nav2 goals it otherwise reaches.
The Sim MCU is a robot of its own, bare_sim: every simulated device on, every pin -1,
and no board, port or baud. Pick Sim MCU as the base controller (MCU & Sim tab, or
Operations) and the cockpit switches to it; pick a real MCU again and it returns to the robot
you had, or to that MCU's bare robot. 1-Click and Bringup run with nothing plugged in: no build,
no probe, no flash.
With no board detected the cockpit does this for you: a banner says No MCU board detected
and the controller switches to the Sim MCU. It switches back when a board appears, unless you
chose the Sim MCU yourself; your choice always stands. A 1-Click run that finds no board on the
bus falls back to it as well, and says so; --require-board makes that an error instead.
The Sim MCU is the real firmware, compiled for the robot computer (firmware/host/app, built
into the Docker image). It is a micro-ROS UDP4 client of micro_ros_agent — the path an ESP32
takes over Wi-Fi — and its own LD19 emulator streams to the LiDAR driver. So a boardless run
still goes through micro-ROS, the agent, the XRCE session and the LiDAR driver, with the same
wheel model, sensor noise and env keys as a flashed board. From a shell:
python3 scripts/one_click_pipeline.py --controller simros2 launch linorobot2_cockpit bringup.launch.py sim_base:=true is something else: it runs
scripts/sim_base_node.py, an rclpy stand-in that publishes odom/unfiltered and imu/data
straight into DDS with no micro-ROS at all, while scripts/sim_laser_node.py raycasts the room
for the scan and the sonar cone on the host. It is the one place the host raycasts the room: a
CI instrument, a way to debug SLAM and Nav2 without the board's scan path, and the fallback for
a checkout without the host firmware build.
base_controller.simulation.world picks what the simulated sensors see. Set it in the
Simulated World Environment section of the MCU & Sim tab, or per run with
--world {wall,rooms,map} / --world-map FILE.yaml. It takes effect at the next flash or the
next Sim MCU start, and it is ignored on a real robot.
world |
what it is |
|---|---|
wall (default) |
a 10×6 m room centred on the origin, with the one obstacle wall (x = 2.0) the Nav2 goal sits behind |
rooms |
the same box as four spaces joined by 1.2 m doors, with no obstacle wall — the world for frontier exploration |
map |
a saved map (simulation.world_map), e.g. one a SLAM or exploration run wrote, raycast by the Sim MCU, or on a board by the robot computer's laser (a board cannot hold a map). The robot starts at the map's origin; there is no collision in a map world |
The room size and the wall are env keys too (map_width, map_height, wall_obstacle,
wall_x1 … wall_y2), and simulation.walls adds interior walls of your own to either box.
Where a robot's LiDAR plugs in follows from what the robot has:
- Most robots: the MCU talks micro-ROS over serial to the robot computer, and the LiDAR is plugged into the robot computer, which runs its driver.
-
A robot with no robot computer on board: the MCU talks over Wi-Fi (
udp4) and the LiDAR, wired to the MCU, goes out over Wi-Fi too (lidar.comm_mode: udp). -
A LiDAR wired to the MCU on a robot with a robot computer: the MCU forwards its frames as
raw_scanover micro-ROS (topic), and only on a serial link fast enough to carry them beside the 50 Hz control loop. That means native USB (Pico, Pico 2, XRP, an ESP32-S3 on its USB-Serial/JTAG port) or a UART at 1.5 Mbaud or more (the Waveshare General Driver's 1.5 Mbaud, the UNO Q's 4 Mbaud).
In simulation the board raycasts the room: its LD19 emulator (use_sim_ld19) builds real LD19
frames, 47 bytes each, from the simulated pose. The LD driver (ldlidar_stl_ros2) turns
them into /scan, exactly as it would a real LD19's, so the scan travels the path a real one
does. lidar.comm_mode says which way the frames go:
comm_mode |
How the frames travel | What reads them |
|---|---|---|
serial |
out a UART pin into a USB-serial bridge on the robot computer, as the General Driver's LiDAR line does (its LiDAR USB-C port, /dev/ttyUSB1) |
the LD driver, on that tty |
topic |
as raw_scan (std_msgs/UInt8MultiArray) over micro-ROS, on the same link as odometry and the IMU |
the LD driver, subscribed to raw_scan
|
udp |
UDP datagrams over Wi-Fi (ESP32), or on this computer (the Sim MCU) | the LD driver, as a UDP server |
Whether a link can carry raw_scan:
| Link | Measured in topic mode | Default for a bare board |
|---|---|---|
| RP2040 / RP2350, native USB |
/odom and /imu at 50 Hz, raw_scan 85–100 Hz |
topic |
ESP32-S3, native USB (USB-Serial/JTAG, /dev/ttyACM0, console: usb) |
/odom and /imu at ~50 Hz with raw_scan at ~77 Hz |
topic |
UNO Q, 4 Mbaud UART (/dev/ttyHS1) |
raw_scan about 15 % of the link |
topic |
| A 921600-baud UART: an ESP32 DevKit, or an ESP32-S3 on its UART port like the Yahboom YB-EET01's CP2102 | every topic drops to 40–45 Hz: the link cannot carry the scan beside the control loop |
serial; topic is refused |
micro-ROS over Wi-Fi (udp4) |
measured with a real LD19 on a General Driver: raw_scan 3.3 Hz, /scan 0.8 Hz, and the IMU starved to 3.4 Hz (the same board on its 1.5 Mbaud cable: 37.5, 10 and 40 Hz) |
udp: never topic over Wi-Fi |
Bringup, the pipeline and the env writer refuse topic on a link slower than 1.5 Mbaud. Over
Wi-Fi, use udp: the board sends the LiDAR straight to the robot computer, outside micro-ROS, and
on the same robot every packet arrived (3,798 of 3,798, each passing the LD19's own CRC).
A LiDAR on the robot computer cannot be raycast in Sim mode. A serial LiDAR whose port
does not exist has no path from the board's emulator, which only reaches the robot computer
through a bridge on its LIDAR_RXD, as raw_scan or over Wi-Fi. A bare ESP32 DevKit is that
case: one USB, for micro-ROS, at 921600. Bringup says NO /scan and starts no LiDAR driver,
and the run proves the topics and the drive, with no SLAM or Nav2. In Real mode the LiDAR is
there and the LD driver reads it. To run SLAM and Nav2 on such a board in Sim mode anyway, ask
for the robot computer's laser (below).
The Sim MCU streams its emulator's frames to the LD driver over UDP. On a map world it
raycasts the saved map itself, sonar cone included: it runs on the robot computer, where the map
file is.
The robot computer's laser (scripts/sim_laser_node.py) raycasts the room itself in three
special cases, and bringup says which:
-
No board,
sim_base:=true.scripts/sim_base_node.pyand the laser together publish the odometry, IMU, scan and sonar straight into DDS, with no board, micro-ROS, agent or LD driver in the loop. Use it to debug SLAM and Nav2 against a scan that cannot be the board's fault. It also stands in when the image has no Sim MCU build. -
Asked for:
host_laser:=true(bringup) or--host-laser(the 1-Click pipeline), Sim mode only. For a board whose link cannot carry its simulated scan, an ESP32 or an ESP32-S3 on its UART at 921600 with no LiDAR bridge, so SLAM and Nav2 still run on it. Native USB and the fast UARTs (1.5 and 4 Mbaud) do not need it. -
A
mapworld on a board. A board cannot hold a saved map, so the robot computer's laser raycasts it.
All of them raycast from geometry.laser.x, so the scan and the base_link → laser transform
agree whichever side produced it.
Note
An ESP32-S3 DevKit has two USB ports; the bare config uses the native one. The S3 image
puts its console and micro-ROS on the native USB port (/dev/ttyACM0), and the generated
bare_esp32s3 config says so (serial_port: /dev/ttyACM0, console: usb). The UART port is
the option, set per board: console: uart0 with serial_port: /dev/ttyUSB0, as the Yahboom
YB-EET01 does (its only USB is a CP2102 on UART0).
Simulation mode is a whole-robot mode: the bare module and the Sim MCU simulate everything, and a real robot simulates nothing. There is no half-way robot.
flowchart LR
Bare["Bare module<br/>all simulated"]
Build["Wire and test each part<br/>with its test tool<br/>(config: all real)"]
Real["Real robot<br/>bringup, SLAM, Nav2<br/>(mode: Real)"]
Bare ==> Build ==> Real
classDef s fill:#0b3b8c,stroke:#7db8ff,stroke-width:2px,color:#fff;
class Bare,Build,Real s;
Important
Sim and real cannot mix. The result of a mix is useless. A real IMU with simulated wheels
or a simulated LiDAR, or a simulated IMU on real wheels, tells the odometry, the EKF and SLAM
about a different robot each, so nothing such a run measures or maps means anything. The
cockpit refuses it, when the env block is written and again at bringup, with
[MIXED SIMULATION REFUSED].
So the robot's own config has every simulation toggle off from the moment it is created, and the
build-up happens with the tool, not with
the robot firmware. Each of its commands runs on the board by itself and tests one subsystem, so it
works on a half-built robot: sensors for the IMU, magnetometer and battery, motor, enc,
motors and acc for the motors and encoders, adc for an ESP32's battery sense, and i2c for
the bus. Bringup, SLAM and
Nav2 run once everything is wired and tested, and the robot runs real. The steps
are in Building a Real Robot.
A command that tests a simulated part is refused in the tool's own words, [tool] 'motor' tests the wheels, and this robot's config simulates them: motor, motors, ramp and acc on
simulated wheels, adc on a simulated battery. The tool uses real parts only (on a board; the
Sim MCU's walkthrough mode, COCKPIT_TOOL_SIM_WALK=1, uses its models), so sensors
reports what is really on the bus, never a simulated IMU. On a real robot's config nothing is
simulated and nothing is refused. The tool is in the one image,
so reaching it needs no env write and no reflash: type tool at boot (or press Enter tool on
Hardware Tests), and base goes back to the robot firmware.
A flashed bare board exercises micro-ROS, its transport, the board's loop timing, the env
partition and the flash; the Sim MCU keeps micro-ROS and the agent but drops the board's serial or
radio link, its timing and the flash; sim_base:=true drops micro-ROS too. None of them tests wiring,
real sensor quirks or real motors. A green boardless run beside a red hardware leg tells you the
fault is in the transport or the board.
When a simulated run goes wrong, see Troubleshooting; a scan at an angle to the walls is a heading problem, covered in Heading & Magnetometer Calibration.
Next: The One-Click Pipeline, or Building a Real Robot when you start wiring real hardware.