Skip to content

Simulation Mode and the Bare Module

hippo5329 edited this page Oct 15, 2026 · 27 revisions

Simulation Mode & 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

MCU & Sim tab with the Sim MCU selected and the Sim Mode preview

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/data and /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 with imu: NONE and 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, /pressure and /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. /battery is 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.

Why it matters

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.

The bare module is the default test subject

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.

Back to a bare module

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.

The simulated robot

The simulated sensors are a typical real one

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.

Simulation mode covers all three drivetrains

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.

The simulated wheels are a real motor, not a ramp

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.

Ask what the motors can do, without flashing anything

python3 scripts/drivetrain_report.py --params config/reference/gendrv_config.yaml

gives 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 whole stack, with no board at all: the Sim MCU

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 sim

ros2 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.

The simulated world

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 the virtual room is raycast

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_scan over 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.py and 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 map world 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).

From the bare board to a real robot

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;
Loading

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.

What simulation mode does not test

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.

Clone this wiki locally