Switch a live SLAM map to AMCL

Start SLAM and map the area first, for example with Simple navigation. The configuration must support both SLAM and localization (contain AMCL settings). New configurations saved by the panel’s generator include both; add AMCL settings to older SLAM-only configurations before switching. Keep the robot stationary during the switch and confirm the map covers the remaining route. The cutover follows five stages:

  1. Pause route execution, cancel navigation goals and publish zero velocity.

  2. Read the robot’s pose with Spatial.LookupTransform(map, base_link).

  3. Call Navigation.SaveMap to create a normal map-library entry.

  4. Start a localization deployment with the existing configuration, saved map ID and captured initial pose.

  5. Wait for that deployment generation to become ready, then resume execution.

The panel uses olo.internal.navigation.v1.Navigation.ControlRoute(pause) before cancellation and ControlRoute(resume) after readiness. It resumes only a route that was playing before the switch, checks the route definition and revision, and leaves an already paused route paused. Those dashboard route RPCs are internal, not public SDK methods. For these standalone SDK examples, pause any dashboard route and stop exploration first; your SDK route loop must stop issuing goals until the example returns successfully.

The public SDK calls below perform the same map and deployment cutover. Base braking uses Locomotion.Stop: Kinematics.Stop cancels joint motion and does not publish base zero velocity. Namespace-scoped handles keep every call on the same robot.

import time
from datetime import datetime, timezone

from olo import Client
from olo.platform import NavigationDesiredState, NavigationMode, NavigationObservedState, Pose2D


def main() -> None:
    """Promote stationary SLAM to a persisted localization deployment."""
    with Client() as client:
        robot = client.robot()
        previous = client.platform.navigation.deployment(namespace=robot.namespace)
        spec = previous.desired
        if (spec is None or spec.mode != NavigationMode.SLAM
                or spec.state != NavigationDesiredState.RUNNING or not spec.config_id):
            raise RuntimeError("Start SLAM before promoting its map")

        # 1. Pause your route loop and any dashboard route before running this script.
        try:
            robot.navigation.cancel_all()
        finally:
            robot.locomotion.stop()

        # 2. Capture the map-frame pose from appliance TF.
        # Bare map is global in Spatial; Nav2 uses a map in the robot namespace.
        map_frame = f"{robot.namespace}/map" if robot.namespace else "map"
        pose = robot.frames.lookup(map_frame, "base_link")
        _, _, yaw = pose.rotation.to_rpy()

        # 3. Save a library map, keeping the robot stationary throughout.
        map_info = robot.navigation.save_map(f"SLAM snapshot {datetime.now(timezone.utc).isoformat()}")
        print(f"Saved {map_info.name} [{map_info.id}]")
        after_save = robot.frames.lookup(map_frame, "base_link")
        if not pose.isclose(after_save, tol=0.02):
            raise RuntimeError("Robot moved during the snapshot; keep it stationary and retry")
        current = client.platform.navigation.deployment(namespace=robot.namespace)
        if current.desired is None or current.desired.generation != spec.generation:
            raise RuntimeError("Another deployment replaced the SLAM session")

        # 4. The reconciler restarts Nav2 and injects the captured AMCL seed.
        started = client.platform.navigation.start(
            namespace=robot.namespace,
            mode=NavigationMode.LOCALIZATION,
            config_id=spec.config_id,
            map_id=map_info.id,
            initial_pose=Pose2D(x=pose.translation.x, y=pose.translation.y, yaw=yaw),
            lidar3d=spec.lidar3d,
            has_laser_scan=spec.has_laser_scan,
        )
        if started.desired is None or not started.desired.generation:
            raise RuntimeError("Deployment returned no generation")
        generation = started.desired.generation

        # 5. Only this generation can authorize the caller to resume its route loop.
        deadline = time.monotonic() + 120
        while time.monotonic() < deadline:
            info = client.platform.navigation.deployment(namespace=robot.namespace)
            if (info.desired is None or info.desired.generation != generation
                    or info.desired.state != NavigationDesiredState.RUNNING):
                raise RuntimeError("Localization deployment was replaced; leave the route paused")
            observed = info.observed
            if observed is not None and observed.generation == generation:
                if observed.state == NavigationObservedState.FAILED:
                    raise RuntimeError(observed.error_message or "Localization failed")
                if (observed.state == NavigationObservedState.READY
                        and observed.mode == NavigationMode.LOCALIZATION and observed.map_id == map_info.id):
                    break
            time.sleep(1)
        else:
            raise TimeoutError("Localization timed out; leave the route paused and inspect Nav2 logs")
        print("AMCL is ready. Your route loop may now resume.")


main()
import { connect } from "olo/web";

const client = connect();
const robot = await client.robot();
const previous = await client.platform.navigation.deployment(undefined, robot.namespace);
const spec = previous.desired;
if (!spec || spec.mode !== "slam" || spec.state !== "running" || !spec.configId) {
  throw new Error("Start SLAM before promoting its map");
}

// 1. Pause your route loop (and any dashboard route) before running this script.
try {
  await robot.navigation.cancelAll();
} finally {
  await robot.locomotion.stop();
}

// 2. Read appliance TF; no browser pose or coordinate conversion is needed.
// Bare map is global in Spatial; Nav2 uses a map in the robot namespace.
const mapFrame = robot.namespace ? `${robot.namespace}/map` : "map";
const pose = await robot.frames.lookup(mapFrame, "base_link");
const [, , yaw] = pose.rotation.toRpy();

// 3. Persist the live SLAM map.
const name = `SLAM snapshot ${new Date().toISOString()}`;
const map = await robot.navigation.saveMap(name);
console.log(`Saved ${map.name} [${map.id}]`);
const afterSave = await robot.frames.lookup(mapFrame, "base_link");
if (!pose.isClose(afterSave, 0.02)) {
  throw new Error("Robot moved during the snapshot; keep it stationary and retry");
}
const current = await client.platform.navigation.deployment(undefined, robot.namespace);
if (current.desired?.generation !== spec.generation) {
  throw new Error("Another deployment replaced the SLAM session");
}

// 4. Write durable desired state; the reconciler restarts Nav2 and seeds AMCL.
const started = await client.platform.navigation.start({
  namespace: robot.namespace,
  mode: "localization",
  configId: spec.configId,
  mapId: map.id,
  initialPose: { x: pose.translation.x, y: pose.translation.y, yaw },
  lidar3d: spec.lidar3d,
  hasLaserScan: spec.hasLaserScan,
});
const generation = started.desired?.generation;
if (!generation) throw new Error("Deployment returned no generation");

// 5. Only this generation can authorize the caller to resume its route loop.
let ready = false;
const deadline = Date.now() + 120_000;
while (Date.now() < deadline) {
  const info = await client.platform.navigation.deployment(undefined, robot.namespace);
  if (info.desired?.generation !== generation || info.desired.state !== "running") {
    throw new Error("Localization deployment was replaced; leave the route paused");
  }
  const observed = info.observed;
  if (observed?.generation === generation) {
    if (observed.state === "failed") throw new Error(observed.errorMessage || "Localization failed");
    if (observed.state === "ready" && observed.mode === "localization" && observed.mapId === map.id) {
      ready = true;
      break;
    }
  }
  await new Promise((resolve) => setTimeout(resolve, 1000));
}
if (!ready) throw new Error("Localization timed out; leave the route paused and inspect Nav2 logs");
console.log("AMCL is ready. Your route loop may now resume.");

Snapshots remain ordinary library maps that users can rename or delete. The deployment retains the map ID, configuration and seed. With Relaunch Nav2 on reconnect enabled in Settings, an appliance restart reapplies localization on that map. The existing opt-in still applies: when disabled, the platform clears old running intent on reconnect instead of restarting navigation. Keep the map while its deployment is in use.

Failure leaves execution stopped; there is no automatic rollback to SLAM or automatic route resume. A saved map remains available even if startup fails. After submitting a deployment, inspect its generation and logs before retrying: a lost response can still mean the intent was accepted. The panel offers retry only before deployment submission. Closing the visualizer or changing robots ends its orchestration and leaves the route paused.