Phat C. Vo
โ† Back to Writing

When Multiple AMRs Share One Warehouse

Published Reading 10 min

Companion to video 22. ๐Ÿ“บ Watch: link coming with the video.

Everything so far assumed one robot in an empty building. Add a second robot and several assumptions quietly stop holding.

Status: this phase is not started. No namespaced spawn, no multi-robot launch, no traffic layer. This article is design plus the arithmetic of this building, which is the part that transfers regardless of what gets built.

1. What breaks when there are two

flowchart TD
  A["one robot"] --> B["two robots"]
  B --> C["topic names collide<br/>/scan, /odom, /cmd_vel"]
  B --> D["TF trees collide<br/>two 'base_footprint'"]
  B --> E["each robot is a moving obstacle<br/>the other's map does not contain"]
  B --> F["both plan as if alone<br/>and both are right, separately"]
  B --> G["a 1.4 m aisle fits ONE"]
  B --> H["two robots want the same dock"]

  classDef bad fill:#fecaca,stroke:#b91c1c,color:#1c1917
  class C,D,E,F,G,H bad

The first two are plumbing. The last four are the actual problem, and no amount of namespacing touches them.

2. Namespacing

flowchart LR
  subgraph R1["/robot_1"]
    A1["/robot_1/scan"] --> B1["/robot_1/slam or amcl"]
    B1 --> C1["/robot_1/map โ†’ /robot_1/odom"]
    C1 --> D1["/robot_1/base_footprint"]
  end
  subgraph R2["/robot_2"]
    A2["/robot_2/scan"] --> B2["/robot_2/amcl"]
    B2 --> C2["/robot_2/map โ†’ /robot_2/odom"]
    C2 --> D2["/robot_2/base_footprint"]
  end
  M["shared map<br/>one map_server, or one per robot<br/>loading the same file"] --> B1
  M --> B2

  classDef shared fill:#fde68a,stroke:#b45309,color:#1c1917
  class M shared

Every topic and every frame gets a prefix. Three things make this less mechanical than it looks:

TF frames need prefixing too, not just topics. Two robots publishing base_footprint into one tree is not a name collision that errors โ€” it is a tree with two children of the same name, and lookups return whichever arrived last.

The map frame is the shared one. Each robot localises privately, in its own map โ†’ odom, but all of those corrections are expressed relative to the same map. That is what makes robot 1’s pose meaningful to robot 2.

use_namespace is not free in Nav2. Costmap global_frame, the behaviour tree’s frame parameters and every launch remap have to agree, and a single one left unprefixed produces a robot that plans in someone else’s frame.

3. One map, private localisation

SharedPer robot
the static map filethe particle filter
the map framemap โ†’ odom
the traffic layer’s viewthe local costmap
the dock and station registryodometry, safety, drive

The reason localisation stays private is that it is a belief, and beliefs differ. Robot 1 being 0.2 m out is robot 1’s problem to correct, and merging estimates would propagate one robot’s error into another’s.

The reason the map is shared is that the building does not change per robot, and two robots with independently-built maps have no common coordinate system to negotiate in.

4. The arithmetic of this building

This is where a general article becomes a specific one. BEEBOT2’s envelope is 0.50 ร— 0.53 m, swept diameter 0.729 m.

SpaceWidthTwo robots passing?
central corridor4.8 myes, comfortably โ€” 4.8 โˆ’ 2 ร— 0.53 = 3.74 m spare
cross aisle2.0 myes, tight โ€” 0.94 m spare, but no room to turn
standard aisle1.8 mmarginal โ€” 0.74 m spare before any costmap margin
pinched aisle1.4 mno โ€” 0.34 m spare, less than one footprint padding either side

And once the costmap’s footprint_padding (0.10 m) and the inflation gradient (0.65 m) are included, the planner’s answer is harsher than the tape measure’s: in a 1.8 m aisle the navigable ribbon is about ยฑ0.40 m about the centreline (article 16), which two robots cannot both occupy.

So the standard aisle is single-file, and the pinched aisle is single-file with no possibility of reversal. That is not a tuning outcome; it is the building.

For the larger 1.14 ร— 0.94 m AMR, with a 1.477 m swept diameter, the pinched aisle is impassable for one robot, let alone two.

Design consequence: in a building like this, traffic management is not an optimisation. It is the difference between working and deadlocked, and it has to exist before the second robot is switched on.

5. Robots as obstacles that plan back

The naive approach โ€” let each robot see the other in its own scan and treat it as an obstacle โ€” works, up to a point, and then fails in a specific way:

sequenceDiagram
  autonumber
  participant A as robot 1
  participant B as robot 2

  A->>A: sees robot 2 ahead, marks it in the local costmap
  B->>B: sees robot 1 ahead, marks it in the local costmap
  A->>A: replan around it โ€” swing left
  B->>B: replan around it โ€” swing left (mirrored: its own right)
  Note over A,B: both swing into the same gap
  A->>A: replan again
  B->>B: replan again
  Note over A,B: livelock: two controllers optimising<br/>against a partner that keeps changing

The failure is not collision, it is livelock. Each robot is behaving correctly against a model that assumes the obstacle is passive, and the obstacle is not passive โ€” it is running the same algorithm.

Reactive avoidance is still worth having as a last line, because it handles the things the fleet layer cannot know about: a person, a dropped pallet, a forklift. But it cannot be the mechanism for robot-to-robot traffic.

6. Traffic management

The alternative is a layer that knows about all of them and grants the right to occupy space:

flowchart TD
  FM["traffic layer"] --> SEG["the map is divided into<br/>segments: aisles, junctions, docks"]
  SEG --> RES{"is the segment free?"}
  RES -->|yes| G["grant โ€” robot proceeds"]
  RES -->|no| W["hold at the segment boundary"]
  G --> REL["robot reports clear<br/>โ†’ segment released"]
  W --> RES

  classDef key fill:#bbf7d0,stroke:#15803d,color:#1c1917
  class G,REL key

Two properties make this work and are easy to omit:

Hold at the boundary, not inside. A robot that stops in the middle of a single-file aisle blocks it for everyone, including itself if it needs to reverse out. Waiting happens where waiting is cheap โ€” at a junction, in a corridor, in a lay-by.

Release on report, not on a timer. A segment freed by a timeout is a segment two robots may believe they own. The robot that leaves says so, and the robot that dies says nothing โ€” which is why every grant needs a liveness condition, not an expiry.

And a claim ordering rule, because otherwise the layer just relocates the deadlock: acquire segments in a globally consistent order, or acquire a whole route atomically. Two robots each holding one half of the other’s route is the textbook deadlock, and it is entirely reachable in a warehouse with a loop of aisles.

7. Deadlock in a narrow aisle

The concrete case in this building:

flowchart LR
  R1["robot 1<br/>heading east"] --> AISLE["standard aisle<br/>1.8 m ยท single file"]
  R2["robot 2<br/>heading west"] --> AISLE
  AISLE --> D["both inside, facing<br/>neither can pass<br/>neither can turn<br/>(swept diameter 0.729 m<br/>vs 0.74 m of spare width)"]

  classDef bad fill:#fecaca,stroke:#b91c1c,color:#1c1917
  class D bad

Turning around is not available: the swept diameter is 0.729 m against roughly 0.74 m of spare aisle width, before any costmap margin. So the only resolutions are:

ResolutionRequires
one robot reverses outreversing the full length of the aisle, under safety
prevention: single-occupancy segmentsa traffic layer, before the fact
prevention: one-way aislesa route graph with directions

Note that the first one depends on article 18. A robot reversing down an aisle with another robot in front of it is driving with its rear field guarding the motion โ€” and if a protective stop could not be escaped by reversing, the recovery would not exist at all.

That is the series’ running theme arriving at the end: the deadlock fix that moved the benchmark by two goals โ€” inside the noise โ€” is what makes a two-robot recovery possible. Some fixes are worth more than their headline number.

8. Mission management

Who gets which task is a scheduling problem, and the naive answer โ€” nearest robot wins โ€” is wrong in a way that is easy to demonstrate:

FactorWhy nearest-robot ignores it
batterythe nearest robot may not finish the job
current missioninterrupting a loaded robot to drop its load is expensive
trafficthe nearest robot may be behind three others in a single-file aisle
dock queuethe nearest robot may be due at a charger
load stateonly some robots may be able to carry this

Travel time, not distance. In a building whose aisles are single-file, those two are not proportional โ€” and the difference between them is the traffic layer’s model.

9. Simulating it

The cheapest useful version, and the natural next step for this workspace:

  1. Namespaced spawn. Two BEEBOT2s in the existing warehouse, each with its own controllers, EKF, AMCL and Nav2, prefixed throughout.
  2. A shared map, loaded twice. No new infrastructure needed.
  3. Scenario tests. The obvious first three: head-on in a standard aisle, both robots to the same dock, both robots to the same pick station.
  4. Measure the same things. nav_scenarios.py already reports goal success, ground-truth error, closest approach, cross-track and localisation error. Multi-robot adds two: time spent waiting and deadlocks per hour.

The existing world is already suitable โ€” two docks, four pick/drop stations, a wide corridor, three grades of aisle and one deliberately impassable corner. It was generated with clearance arithmetic against the robot’s radius, and that arithmetic is exactly what a traffic layer needs.

10. What is real today

Status
beebot2_interfaces seamโœ… one message type
namespaced spawnโฌœ not started
multi-robot launchโฌœ not started
traffic / segment managementโฌœ not started
mission allocationโฌœ not started
launch_testing scenario suiteโฌœ not started

Phase 10 has not begun, and it should not begin before Phase 6 does. A fleet of robots that each reach 44 % of their goals is not a fleet; it is nine simultaneous debugging sessions.

The end of the series

Twenty-two articles, and the robot went from an empty workspace to โ€” honestly โ€” a machine that drives on hardware, and does everything else in simulation. The gap between those two is written down rather than glossed over, subsystem by subsystem, which was the point.

What the series was actually about is visible in retrospect:

flowchart TD
  A["nothing"] --> B["drives"]
  B --> C["takes commands"]
  C --> D["has a body, and a copy of itself"]
  D --> E["feels its own motion"]
  E --> F["sees"]
  F --> G["knows its own power"]
  G --> H["remembers a place"]
  H --> I["knows whether the memory is good"]
  I --> J["knows where it is"]
  J --> K["goes somewhere"]
  K --> L["fails, and is debugged"]
  L --> M["protects itself"]
  M --> N["escapes its own protection"]
  N --> O["charges ยท works ยท joins a fleet"]

  classDef done fill:#bbf7d0,stroke:#15803d,color:#1c1917
  classDef partial fill:#fef3c7,stroke:#b45309,color:#1c1917
  classDef todo fill:#e7e5e4,stroke:#57534e,color:#1c1917
  class A,B,C,D,E,F,G done
  class H,I,J,K,L,M,N partial
  class O todo

ROS 2, Gazebo, LiDAR, IMU, SLAM and Nav2 were never the subject. They showed up when the robot needed them, in the order it needed them, and each one arrived carrying a problem the previous article could not solve.

And one rule held for the whole series:

Nothing is called “done” because it ran once. Every claim in these articles carries the measurement behind it, or it is labelled as untested. Where the real robot disagrees with the simulation, both numbers are shown.

Which is why the last article ends with a table of things that do not exist yet, rather than a highlight reel. That table is the most useful page in the series.

Sign-off

  • every topic and every TF frame is namespaced
  • the map is shared; localisation is private
  • passing width has been computed against the costmap ribbon, not the tape
  • robot-to-robot traffic is managed, not left to reactive avoidance
  • robots hold at segment boundaries, never inside single-file aisles
  • segments are released on report, with a liveness condition โ€” never a timer
  • routes are claimed in a consistent order, or atomically
  • allocation uses travel time, not distance
  • deadlocks per hour is a measured number

Next

That is the series. If you build one of these, the most useful thing you can copy is not any configuration file in it โ€” it is the habit of writing down what has been measured, what has not, and what the difference between them costs.

Back to: the series index.