A distributor forwarded me a customer email last month. Two lines:
"The robot runs for 40 minutes and the floor still looks striped. Is it broken?" stonebotclean.com
The honest answer is that I didn't know. Every spec sheet in this category quotes a runtime, a swath width and a suction power. None of them quote coverage, because coverage is not a spec you can print — it is what happens when a path meets a rectangle with walls on four sides.
So I stopped reading brochures and wrote a simulator. Rasterise the pool at 2 cm, stamp the brush along the path, count how many cells never get touched.
Here is what came out, and the three things I got wrong on the first pass.
**
The model
Nothing fancy. A 12 × 6 m pool (72 m², a normal residential rectangle), a 42 cm effective swath, 0.28 m/s while scrubbing, 6 s for a 180° lane change, 15 cm of standoff so the brush housing clears the wall, and a 150 minute battery.
Four serpentine variants, differing only in lane pitch:
pitch = 1.00 × swath (0.42 m): the textbook answer, lanes exactly tangent
pitch = 0.85 × swath (0.36 m): 15% overlap
pitch = 0.70 × swath (0.29 m): 30% overlap
pitch = 1.30 × swath (0.55 m): what happens when someone tunes the lane spacing up to "finish faster"
Plus one control: a random-bounce run with no planning at all — drive until you hit a wall, deflect, keep going, change heading roughly every 8 seconds. That is what a $300 hobby unit does, and it is the baseline every lidar-equipped robot is implicitly being compared against.
The measurement is a disk stamp: at every 4 cm along the path, mark every 2 cm cell within 10.5 cm of the centre as cleaned. Then count.
def boustrophedon(pitch, pool, bot):
L, W = pool["length"], pool["width"]
stand, w = bot["standoff"], bot["width"]
x0, x1 = stand, L - stand
y0, y1 = stand, W - stand
lane_y, y = [], y0 + w / 2
while y <= y1 - w / 2 + 1e-9:
lane_y.append(y)
y += pitch
if lane_y[-1] < y1 - w / 2 - 1e-6: # edge pass
lane_y.append(y1 - w / 2)
...

That edge pass line is the first thing I got wrong. Without it, the loop stops as soon as the next lane would cross the top wall, and the last 28 cm of the pool simply never gets driven. On the 15% overlap run that was 4.9% of the floor — invisible in a spec sheet, visible to anyone standing at the shallow end.
What the numbers said
Strategy
Lane pitch
Floor reached
Time
Swept area / floor
Lane changes
Tangent lanes
0.42 m
99.66%
11.9 min
1.03×
13
15% overlap
0.36 m
100.00%
13.4 min
1.17×
15
30% overlap
0.29 m
100.00%
15.8 min
1.37×
18
Over-spaced
0.55 m
80.69%
9.5 min
0.83×
10
Random bounce, 1 charge
—
100.00%
150 min
13.68×
1,507
Random bounce, 3 charges
—
100.00%
450 min
41.20×
4,277

**
Finding one: tangent lanes lose 0.34% of the floor to pinholes.
99.66% sounds like rounding. It isn't. When lane centres sit exactly one swath apart, the brushes are tangent, and any raster cell on the boundary line between two lanes has a fighting chance of being missed. In the picture that shows up as a hairline of uncleaned floor every 42 cm — the "striped" look, at one-pixel width. In a real pool, a 1 cm strip is not a defect you can see. In a real pool with 3° of yaw drift and a slightly conical brush, it is.
Finding two: 15% overlap costs 1.5 minutes and buys the last 0.34%.
200.7 m of path instead of 177.3 m, 15 lane changes instead of 13, 13.4 minutes instead of 11.9. Sweep ratio goes from 1.03× to 1.17× — 17% more brush passes over the same floor. That is a cheap insurance policy against yaw drift, brush wear and a slightly out-of-square pool. 30% overlap buys nothing the 15% run didn't already have, and costs 2.4 more minutes plus 17% more brush passes over the floor. If you have to pick one number for a lane pitch, it is around 0.85 × swath, not 1.0.
Finding three: over-spaced lanes are the only strategy that fails visibly, and it fails fast.
0.55 m pitch finishes in 9.5 minutes — the fastest run in the table, 29% quicker than the 15% overlap case. It also misses 19.3% of the reachable floor in 19 cm stripes. This is the exact trade a manufacturer makes when the marketing number is "cleans your pool in 10 minutes", and it is why customers write emails about stripes. Fast, and visibly wrong.
Finding four: random bounce reaches 100%, which is the least useful 100% in the table.
The unplanned run covers every cell inside one 150-minute charge. It also sweeps 13.7 times the floor area to do it — 2,344 m driven, 1,507 wall nudges — which is 11× the runtime of a planned 13.4 minute run. So "coverage" alone cannot rank these strategies. Two numbers, not one: how much of the floor, and how much work per square metre. A lidar robot that plans lanes is not selling coverage. Coverage was never the hard part. It is selling the 1.17×.
Bonus, because it surprised me: turning is 11% of the runtime.
At 6 s per lane change, the 15% overlap run spends 90 of its 804 seconds rotating. On a 6 m width that is fine. Halve the pool width and the lane count barely changes; double it and you are spending a fifth of your battery on pirouettes. Lane pitch affects runtime twice — once through path length, once through lane count.
Where the model lies
I would rather list this than have someone in the comments list it for me.
The drive is lossless. No slip, no wheel scrub on the tiles, no slowdown in the shallow end, no current. Real units lose 5–15% of nominal speed and lose most of it on turns.
The swath is a perfect disk. Real suction mouths lose width at the edges and lose more when the filter is half-full. Effective width drops through a cycle.
No dirt model. "Covered" means "the brush passed over it". Whether one pass removes algae film is a chemistry question I have no data for.
Flat floor only. No slope, no steps, no main drain, no ladder. Wall climbing and waterline scrubbing are outside the raster entirely.
The random run is friendly. 8 seconds between heading changes, no stalling in corners, no getting stuck on the drain cover. Real chaotic navigation is worse than this, so 13.7× is closer to a floor than a ceiling.
One cycle, one direction. No multi-cycle accumulation, no slope-driven drift in the serpentine.
Everything above is a constant at the top of the file. Change BOT["width"] to 0.28 and the whole table shifts; that is the point of writing it down instead of arguing about it.
What I would add next
Two things, in this order.
First, yaw drift. Model the robot leaving each lane 2–3° off axis and let the error compound; that turns the pinhole problem from a raster artefact into a real stripe generator, and it will tell you whether 15% overlap is enough or whether you need 25%.
Second, dirt-weighted coverage. Right now every square centimetre is worth the same. In reality the shallow end and the steps collect most of the debris, and a planner that spends its extra passes there is worth more than one that spends them evenly. That needs a weight map, which needs real pool data, which I do not have yet.
Code, figures and the JSON output are all in one file, roughly 280 lines, numpy and matplotlib only:
python pool_coverage_sim.py # writes results.json + figures/

If you build pool robots, the number I would like from you is not swath width. It is lane pitch, lane-change time, and whether the planner does an edge pass.