Raised beds: a margin, a switch, and a flowerbed mistaken for long grass
The sim's flowerbed is a 15 cm raised box. To the 3D lidar, its edge is an obstacle like any other, and the safety gate never lets the robot's nose get closer than about 0.57 m to an obstacle (the lidar's blind range for a 4 cm object, plus 10 cm). The planner ended stripes 0.26 m from keep-outs. Those two numbers can't both hold, so we decided on option C: by default the robot keeps further from raised keep-outs and leaves the strip for the trimmer, and an owner switch lets it mow right up to them.
Building it turned up three more bugs. A raised bed can pass for long grass, and so can a fence. And the robot's route home could take it into a corner it couldn't get out of.
The baseline: three failures in one run
Before changing anything we mowed the Test patch with the 3D lidar, in 4 cm grass (raised-1-baseline). It lasted
167 seconds:
- Rounding the bed's corner, the gate stopped with the bed 0.55 m ahead. That was expected.
- One second later the long-grass rule tripped: "uncut grass 15.5 cm > 5.5 cm". The robot went home and declared the lawn overdue.
- The grass clearance had ratcheted to its 10 cm cap. Of the three, this is the one that matters.
The second and third come from the same cause. The grass measurement takes the median of the tallest return in each 20 cm cell of the lane ahead. At the corner, the bed's edge ran diagonally across the lane and filled most of its cells, so the median was the bed. The clearance follows the tallest grass measured this mission, and it never comes down (a ratchet, for reasons in the long-grass post). So one look at a flowerbed raised the obstacle threshold for the rest of the mission, and that threshold is the margin that keeps a hedgehog visible.
Reproducing this in a unit test took two tries. A bed square-on and filling the view reads as ground: the per-cell ground correction holds each cell to its neighbours' level, so only cells near an edge stand out. Placed at 45°, as it was at the corner, it reads 14–15 cm. The test uses the 45° bed.
Option C
The lawn model gets a flag: a keep-out can be "raised": true (the flowerbed, and the tree, whose trunk the lidar
sees too). Then:
- Never grass. The safety gate loads the map, takes the robot's pose, and leaves every return inside a mapped raised keep-out (plus 10 cm) out of the grass measurement. This applies whatever the switch says, because a flowerbed is never grass. It also leaves those returns out of the ground fit, since a bed isn't ground either.
- The margin (default). The planner grows raised keep-outs by 0.45 m before planning. Every lap, stripe end and route then keeps the extra distance: the nose ends stripes 0.71 m from the bed, outside the gate's 0.57 m.
- The switch.
mow_up_to_raiseddrops the margin, and the gate then ignores returns inside the mapped bed as obstacles too. Anything outside it still counts, including an animal pressed against the bed's edge. A unit test maps the bed 1 m too far away and checks that the unmapped part stops the robot again. The switch is logged at mission start, like the long-grass switch. On the real robot it will be app-only.
The route home that went the long way
The first runs with option C mowed perfectly, then failed on the way home: "no progress in RETURN". Both stopped at the same spot, (8.9, −7.0), in the lawn's far corner, facing a fence 0.56 m away.
The route home had gone 19.3 m east into that corner instead of 11.1 m round the bed. Routes come from a visibility graph: nodes on the edge of the free area, joined wherever the straight line between them stays inside it. Two nodes on a keep-out's rounded buffer are joined by a chord, and a chord across a curve cuts into the buffer. So every link round the bed was rejected, and the shortest route left ran along the fence. The bigger raised margin made the curve bigger, which is how we found it, but the bug didn't need the margin: the switch-on run, with no margin, got stuck in the same corner.
The fix is to put the nodes 6 cm inside the free area. Simplification moves them by at most 5 cm, so they stay inside, and the chords between them do too. A regression test plans that same route home.
The fence that was long grass
Because the route fix changes every route, we ran a full back-yard mow as a regression check. It lasted 263 seconds. Heading north near the fence, the lane read 29.7 cm of uncut grass, and the long-grass rule sent the robot home.
This one has nothing to do with raised beds. Returns above 30 cm were dropped as "not grass", but only the returns: the bottom 30 cm of a fence or a hedge stayed, and topped every cell it was in. It hadn't shown up before because this was the first back-yard run with the 3D lidar in grass since the long-grass rule went in. Two fixes, one per layer:
- Map: grass is only measured on the mapped lawn. A fence, a wall or a patio past the lawn's edge is never grass.
- Sensing: a 20 cm cell with anything above 30 cm in it is an object, and is dropped whole.
A low wall under 30 cm that isn't on the map would still fool it: the map is what covers that case.
The rerun (backyard-raised-2) had no long-grass trips. It still failed, and the reason was older than anything in
this post. The outer lap runs 0.35 m inside the lawn's edge, so at a corner the robot drives straight at the edge and
its nose, 0.49 m ahead of the axle, ends up 14 cm past it. In the sim the fence stands 0.5 m beyond the lawn's
edge, so the nose was 0.36 m from the fence, inside the 3D lidar's 0.57 m stop field. The robot stopped there and
never moved again. The back yard had never been mowed with the 3D lidar before, so nothing had shown it. On a real
lawn that ends at a wall, the same corner would put the nose into the wall. That gets fixed next.
Results
make raised-test mows the Test patch twice. analysis/raised_keepout_report.py judges each run:
| Check | Margin (default) | Switch on |
|---|---|---|
| Mission | done, docked | done, docked |
| Safety stops near a raised keep-out | 0 | 0 |
| Long-grass trips | 0 | 0 |
| Grass clearance while mowing (cap 10 cm) | 9.8 cm | 9.5 cm |
| Blade edge's closest approach to the bed | 0.57 m (≥ 0.45) | 0.16 m (≤ 0.30) |
| Reachable area cut | 95.5% | 96.8% |
| Whole lawn cut | 63.2% | 80.5% |


The last row is the price of the default. With the margin, about 0.6 m round the bed stays uncut: 5.6 m² more for the trimmer on this small patch. We think that's the right default for now, because it doesn't ask the robot to trust its map near a solid object. The switch is there for an owner who has checked the bed is mapped well.
Smaller things
- The tests interfered with each other. The battery integration test failed in the full suite and passed alone.
colcon runs packages' tests in parallel on one ROS network, and the new gate tests published config events that
the battery test counted.
make testnow runs packages one at a time: 124 tests, about 20 seconds. - The website's diagram tabs did nothing (Chris, on a phone).
display: gridon the panels beat the browser's own[hidden]rule, so all five diagrams always showed. Fixed. On phones each diagram also opens full size.
Next: corners, then ground hazards
First, lap corners. A lawn's edge can be a wall, so the outer lap has to turn before the nose reaches it. Then ground
hazards. Chris raised three more ways to get hurt without hitting anything: falling off a step or wall, a slope too steep,
and a spinning blade over a driveway. Each gets a map layer and an independent sensing layer. The lidar can see
missing or sunken ground ahead, the slope ahead, and a surface too flat to be grass. The plan is in
docs/phase5-perception.md, section 5.1c. Hard surfaces come first.