How OOMWOO cleaning algorithms works

In simulation, our open-source robot vacuum kept grinding to a halt in front of the sofa. The obstacle turned out to be a phantom on the costmap. Here is how we found it by measuring, and the two-part fix that roughly doubled coverage near obstacles.

OOMWOO is our open-source, ROS 2, 3D-printable robot vacuum. In simulation it cleaned open floor just fine – but the moment it approached a wall or the front of the sofa, it would stop and grind in place, replanning over and over, sometimes looping for fifteen minutes. It looked hopelessly stuck. It turned out it wasn’t stuck at all. This is the story of a bug that was easy to misread by eye and obvious the moment we measured it – and the two-part fix that roughly doubled coverage near obstacles.

The symptom

Watching a run, the robot would drive toward furniture, slow down, and then wedge – working its wheels, cancelling and re-issuing navigation goals, burning minutes without making progress. Every instinct said it was physically trapped against the sofa and needed a better escape maneuver.

Diagnosis: measure, don’t guess

Instead of designing an escape behavior on a hunch, we instrumented a stall and logged what was actually happening. Two numbers settled it:

  • 0 bumper contacts during the entire stall – the robot was never physically touching anything.
  • ~90 “collision ahead” aborts from the controller in the same window.

That combination is decisive. The navigation controller (Nav2’s Regulated Pure Pursuit) was aborting on a predicted collision that never physically happened. Its forward collision check projects the robot’s footprint ahead and refuses to enter cells marked lethal on the costmap. Because our obstacle inflation is tuned tight (more on that below), the robot halted about six centimetres before its body would have touched – close enough to look stuck, far enough that the bumper never fired. A phantom obstacle.

Why the costmap is deliberately tight

The robot’s inscribed radius is 0.1745 m. The obstacle-inflation radius is a trade-off, and for a vacuum it wants to be small:

  • Inflate at or above ~0.175 m and every gap narrower than about twice that gets sealed off – the robot never threads between furniture legs, so it never cleans there.
  • Inflate down near 0.02 m and the controller can’t find valid trajectories at all.
  • At 0.10 m it threads the gaps nicely – but grazes obstacles, which is exactly what exposed the phantom.

A vacuum genuinely wants to run tight to walls and furniture – that’s the whole job. So the tight inflation is correct. What’s wrong for this robot is the collision-averse controller behavior on top of it.

The fix, in two parts

The two parts are gated: the first has to happen for the second to do anything, because until the robot actually reaches contact there’s nothing for a bumper-driven behavior to react to.

1. Let the robot reach contact

Turn off the controller’s forward collision check. A vacuum is contact-tolerant: it has a bumper and is meant to touch things. Let it drive right up to walls and furniture, and let the bumper own near-obstacle safety.

# nav2_params.yaml (RegulatedPurePursuitController)
use_collision_detection: false   # was: true

The effect was immediate: collision aborts dropped to zero, and the bumpers started firing – the robot was finally reaching the obstacles it had been hovering in front of.

2. Peel off the held bumper

Reaching contact is only useful if the robot then reacts well. We added a small reactive behavior to the coverage planner, layered under the coverage goals. When a bumper stays pressed continuously for about 1.5 seconds, the planner:

  • cancels the active navigation goal immediately, instead of waiting for Nav2 to give up while the robot leans on the obstacle;
  • turns away from the pressed side – left bumper held, rotate right; right bumper held, rotate left – rather than a blind straight reverse;
  • records a no-go pocket so the coverage sweep doesn’t immediately route straight back in;
  • backs and peels for a moment, then hands control back to Nav2.

This is the classic robot-vacuum “bumper held, panic turn” reflex, keyed on which bumper is pressed rather than on the costmap.

Results

Measured on the same living-room world over a fixed 135-second window:

ConfigurationCoverageCollision abortsPhysical contact
Baseline (stock Nav2)~20%~90–105never touches (phantom)
Combined fix~36%0reaches contact, peels off cleanly
Across two baseline and three fixed runs; variance under one point.

To be clear: ~36% coverage in a 135-second window is not consumer-vacuum performance – this is early simulation work, on one world, and there’s plenty left to tune. The point isn’t the absolute number. It’s that the robot now cleans near obstacles instead of grinding against phantom ones, the improvement is repeatable, and the approach is the right shape to build on.

Why a vacuum should be allowed to touch things

Real robot vacuums don’t escape near-obstacle situations with navigation-stack recoveries. Nav2’s built-in spin and backup recoveries are collision-averse – they check the costmap and refuse to move into the very cells the robot is wedged against, so they stall exactly when you need them. A vacuum has a bumper and is supposed to make contact. The right architecture is a small reactive, bumper-driven behavior layer that runs open-loop, ignores the costmap, and overrides the planner while in contact. Coverage plans the open floor; this reflex handles the last few centimetres. (Anyone who has read iRobot’s coverage patents will recognize the pattern: the escape logic keys on the bumper, not the map.)

Try it yourself

Everything here runs headless in the OOMWOO dev container. Run it with GUI – no robot needed:

docker pull makerspet/oomwoo:jazzy-dev
docker run -d --name oomwoo makerspet/oomwoo:jazzy-dev sleep infinity
docker exec -it oomwoo bash

# inside the container:
GZ=$(ros2 pkg prefix oomwoo_gazebo)/share/oomwoo_gazebo
SIM=$(ros2 pkg prefix oomwoo_sim_support)/share/oomwoo_sim_support

ros2 launch oomwoo_sim_support coverage_regression.launch.py gui:=true \
  world:=$GZ/worlds/living_room.world \
  map:=$SIM/maps/living_room.yaml \
  x_pose:=0.32 y_pose:=1.59

Run it headless – no display, no GPU needed:

ros2 launch oomwoo_sim_support coverage_regression.launch.py \
  world:=$GZ/worlds/living_room.world \
  map:=$SIM/maps/living_room.yaml \
  x_pose:=0.32 y_pose:=1.59

Watch the log for bumper held ... peel off lines (the escape firing) and echo /coverage_meter/ratio from a second shell to see coverage climb. To see the “before”, relaunch with contact_aware:=false robot_radius:=0.1 and set use_collision_detection: true back in the params file oomwoo-ros2-tools\src\oomwoo_sim_support\config\nav2_params.yaml – the robot will grind in front of the sofa again. Sim variance is real, so run each side a few times. The code lives in oomwoo-ros2-tools.

What’s next – and how to help

We’ve implemented one of the three classic escape reflexes (bumper held, panic turn). Two more are still open: “frequent bumps means confined, so edge-follow out”, and “no bumps over a long travel means high-centered, so spiral”. Beyond that: tuning the escape across more worlds, running to completion rather than a fixed window, and eventually re-validating on real hardware once the physical bumper exists.

OOMWOO is built in the open, module by module. If reactive behaviors, coverage planning, or robot navigation are your thing, come say hello in the GitHub Discussions or on Discord – there’s a whole board of modules waiting for a builder.

Leave a Reply

Your email address will not be published. Required fields are marked *