The robot's own mind · Chapter 24 · Time: 3 hours · Level: Advanced · Status: Partly test-built
The robot's four wheel skills as on-demand systemd units - exploring the room with explore_lite, practising small moves to learn its own body, coming to you, going home - and the rules that decide who may start them. You also get the honest record of how they have done on the floor.
Navigation (chapter 18) runs all the time but never moves by itself: it waits for a goal. The wheel skills are the
programs that give it goals. Each one is its own systemd unit that is never started at boot, runs once per start,
is a client of the always-on navigation, and disables the wheels on every way out. The robot's mind starts two of
them on its own (practice and explore) when its rules allow it; Buddy starts the other three (come, home, set home)
when you ask; you can start any of them by hand.
New idea: frontier exploration
A map being built has three kinds of cells: free (seen, nothing there), occupied (an obstacle) and unknown (not
seen yet). A frontier is a stretch of free cells that borders unknown ones: going there shows something new.
A frontier explorer repeatedly picks the most promising frontier (big, and not too far) and sends it to Nav2 as
a goal, until no frontier bigger than a minimum size is left.explore_liteis the standard ROS 2 one, from
the m-explore-ros2 project (BSD licence). In a closed room it runs out of frontiers fast: 13 s after the start
on 2026-10-02, because the LiDAR sees the whole room from the middle. So the robot's explorer adds a second phase
of its own: drive to the floor it has not driven over yet.
New idea: the two Nav2 actions used here
An action (chapter 2) is a long request with progress and a final result.NavigateToPoseasks Nav2 to
drive to a pose on the map; its result status is 4 SUCCEEDED, 5 CANCELED or 6 ABORTED.AssistedTeleopis the
opposite: you send velocity commands yourself on the topiccmd_vel_teleop, and Nav2's behaviour server checks
each one against the local costmap and slows or stops it before a collision, then passes it through the same
velocity smoother and collision monitor as every other motion. Practice uses it so its own made-up moves are
collision-checked like navigation.
explore_lite was built from source in ~/ros2_ws (the record does not say whether an apt package was looked
for). The robot runs
robo-friends/m-explore-ros2, branch main at commit 326cf8a ("Do not blacklist for preempted goal (#51)",
2026-06-01). The repository also holds map_merge, a tool for merging the maps of several robots; it is not used
here, and an empty COLCON_IGNORE file makes colcon skip its folder.
On the robot:
cd ~/ros2_ws/src
git clone https://github.com/robo-friends/m-explore-ros2.git
cd m-explore-ros2 && git checkout 326cf8a && git log --oneline -1
touch map_merge/COLCON_IGNORE
source /opt/ros/humble/setup.bash
cd ~/ros2_ws && colcon build --packages-select explore_lite_msgs explore_lite 2>&1 | tail -4
ls install/explore_lite/lib/explore_lite
Check
Both packages build and theexploreprogram exists. From 2026-10-02 07:11 UTC:326cf8a Do not blacklist for preempted goal (#51) Finished <<< explore_lite [38.9s] Summary: 2 packages finished [47.5s] explore
If it fails
Failed to find the following files: .../install/explore_lite_msgs/share/explore_lite_msgs/package.sh:
you builtexplore_litealone. It needs its message package; build both, as above. This was the first
attempt on 2026-10-02.
Not test-built: the pinned clone
On 2026-10-02 the robot cloned withgit clone -q --depth 1(the newest commit ofmainthat day, which was
326cf8a) and built without setting a build type. The clone-then-checkout above gets the same commit on a later
day; it has not been run in this form.
ros2/rosorin_base/config/explore.yaml is installed with the rosorin_base package (rebuild it with
colcon build --packages-select rosorin_base after adding the file):
On your laptop:
# explore_lite (m-explore-ros2, BSD): frontier exploration on the Nav2 global costmap (slam map + LiDAR + nvblox).
# Stock parameters except the costmap topics and the frontier size (one room).
/**:
ros__parameters:
robot_base_frame: base_link
return_to_init: true # no frontier left -> back to where it started
costmap_topic: /global_costmap/costmap
costmap_updates_topic: /global_costmap/costmap_updates
visualize: false
planner_frequency: 0.15
progress_timeout: 30.0
potential_scale: 3.0
orientation_scale: 0.0
gain_scale: 1.0
transform_tolerance: 0.3
min_frontier_size: 0.5
Three values differ from the package's own explore/config/params.yaml: it reads frontiers from Nav2's global
costmap (which already merges the SLAM map, the LiDAR and the depth camera) instead of the map topic, draws no
markers (visualize: false), and accepts frontiers from 0.5 m instead of 0.75 m because it explores one room.
planner_frequency: 0.15 means it picks a new frontier about every 7 s; progress_timeout: 30.0 gives up on a
frontier after 30 s without progress.
behavior/explore_auto.py is 391 lines; read it in the repo. Its own docstring says what it is: "This script only
starts and stops them - no navigation logic here". The parts:
| Part | Job |
|---|---|
| constants | Time limit (600 s, EXPLORE_SECONDS), battery floor, home tries, goal timeouts, what counts as visited. |
Run.__init__ |
Subscribes to the driver state, battery, camera odometry (distance driven), global costmap; clients for wheel enable, Nav2, costmap clears, the mind. |
wheels(), go() |
Wheel enable through the driver; one NavigateToPose goal with a timeout and the stop checks. |
stop_reason() |
Why it must stop now: e-stop, plugged in, low battery. |
run() phase 1 |
explore_lite as a child process until no frontier is left or time is up. |
run() phase 2 |
pick() the reachable floor farthest from everywhere it has been, drive there, repeat, until 90 % covered. |
look_around() |
At each new vantage point (60 s apart) stop, give the arm back to the mind for its own room scan (chapter 21), take it back. |
recover(), escape(), second winds |
Keep going after a setback (owner: "he quits too easy"): clear its own contact-reflex stop, crawl out of an obstacle's inflation ring (unstick.py), fresh costmaps after five failures. |
go_home() |
Up to 4 tries within 360 s to get back to the start. |
speak_outcome() |
Queue a sentence for Buddy (chapter 23): could not get home (urgent), low battery (urgent), home but not plugged in. |
main() |
Pause the mind, run, always disable the wheels, exit 0 only if it got home. |
The limits it works within:
On your laptop:
TIME_LIMIT = float(os.environ.get('EXPLORE_SECONDS', '600'))
LOW_BATTERY_V = 10.0 # owner 2026-10-01; compared against a 20 s average
HOME_TIMEOUT = 120.0 # per try
HOME_TRIES, HOME_BUDGET = 4, 360.0 # tries to get home, and the most time spent on them (2026-10-06: one try quit)
SECOND_WINDS = 2 # after five failed goals in a row: fresh costmaps and on again, this many times
LOOK_EVERY, LOOK_TIMEOUT = 60.0, 45.0 # s between look-around stops; longest wait for the mind's room scan (25 s + looks)
GOAL_TIMEOUT = 60.0
VISIT_R = 0.5 # m: floor this close to where the robot has been counts as visited
GOAL_COST = 60 # goals only on cells cheaper than this (0-100; 99 = touching an obstacle's inflation core)
COVERED = 0.90 # share of reachable floor visited = done
EXPLORE_PARAMS = '/home/burgerbarn/ros2_ws/install/rosorin_base/share/rosorin_base/config/explore.yaml'
The stop checks run in every wait loop:
On your laptop:
def stop_reason(self):
if self.st.get('estop'): return f"e-stop: {self.st['estop']}"
if self.st.get('plugged'): return 'plugged in'
b = self.battery()
if b is not None and b < LOW_BATTERY_V: return f'low battery {b:.2f} V'
return None
"Plugged in" is a stop reason because the robot cannot tell a stand from the floor; a robot on the charger does not
drive. The start of run(), with phase 1:
On your laptop:
def run(self):
end = time.time() + 20
while time.time() < end and not (self.st and self.bat):
rclpy.spin_once(self.n, timeout_sec=0.1)
if self.st.get('estop') == 'estop topic': # its own reflex stopped the last run; a new run = go.
c = self.n.create_client(Trigger, '/rosorin_board_driver/reset_estop') # gamepad e-stops stay the owner's
if c.wait_for_service(timeout_sec=5):
f = c.call_async(Trigger.Request()); end = time.time() + 5
while time.time() < end and not f.done():
rclpy.spin_once(self.n, timeout_sec=0.05)
self.spin(0.5); self.log('estop_cleared', was='estop topic')
why = 'no driver state' if not self.st else self.stop_reason()
if why:
self.log('abort', reason=why); return
if not self.wheels(True):
self.log('abort', reason='wheel enable refused'); return
self.spin(4.0) # arm settled in the drive pose; map + costmaps filling
home = self.pose()
self.t0 = time.time(); self.deadline = self.t0 + TIME_LIMIT; self.next_log = self.t0 + 5
self.log('start', pose=home, battery=self.battery(), limit_s=TIME_LIMIT)
ex = subprocess.Popen(['ros2', 'run', 'explore_lite', 'explore', '--ros-args', '--params-file', EXPLORE_PARAMS],
stdout=open(os.path.join(self.dir, 'explore_lite.log'), 'w'), stderr=subprocess.STDOUT)
reason = None
while reason is None: # phase 1: frontiers (explore_lite)
self.spin(0.2); self.note()
reason = self.stop_reason() or ('time limit' if time.time() > self.deadline else None)
if reason is None and (ex.poll() is not None or (time.time() - self.t0 > 15 and 'Exploration stopped' in
open(os.path.join(self.dir, 'explore_lite.log')).read())):
break
self.resume.publish(Bool(data=False)); self.spin(0.5)
ex.terminate(); self.spin(1.5) # its own "return to start" goal is replaced by ours
Enabling the wheels makes the driver move the arm to the drive pose first and refuse if it cannot (chapter 11). The
explorer only clears the e-stop that its own contact reflex set (estop topic); a gamepad e-stop is the owner's
and stays.
Phase 2 picks goals from the global costmap. It thins the grid to 10 cm, measures how far each free cell is from
the robot's track so far, and picks the farthest cell that is cheap enough and not near a goal that already failed:
On your laptop:
def pick(self):
"""Floor not yet driven over: the reachable-looking cell farthest from everywhere the robot has been.
Returns ((x, y) or None, share of floor visited)."""
c = self.cost
if c is None or not self.track:
return None, 0.0
info, r = c.info, c.info.resolution
C = np.array(c.data, np.int16).reshape(info.height, info.width)[::2, ::2] # 10 cm grid is plenty
jj, ii = np.nonzero((C >= 0) & (C < 99))
if not len(ii):
return None, 0.0
X = info.origin.position.x + (ii * 2 + 1) * r; Y = info.origin.position.y + (jj * 2 + 1) * r
T = np.array(self.track[::3] + [self.track[-1]])
d = np.min(np.hypot(X[:, None] - T[None, :, 0], Y[:, None] - T[None, :, 1]), axis=1)
covered = float((d <= VISIT_R).mean())
ok = (C[jj, ii] < GOAL_COST) & (d > VISIT_R)
for fx, fy in self.failed:
ok &= np.hypot(X - fx, Y - fy) > 0.4
if not ok.any():
return None, covered
k = int(np.argmax(np.where(ok, d, -1.0)))
return (float(X[k]), float(Y[k])), covered
main() wraps everything so the wheels are disabled whatever happens, and the exit code tells the run script
whether the map is worth keeping:
On your laptop:
def main():
rclpy.init(); r = Run()
try:
with mind_paused(reason='room explore'):
r.run()
finally:
try: r.wheels(False)
except Exception as ex: print('disable failed:', ex, flush=True)
sys.stdout.flush()
os._exit(0 if getattr(r, 'clean', False) else 3)
Every run writes ~/explore/<YYYYmmdd_HHMMSS>/log.jsonl (a state line every 5 s, every goal, every stop),
summary.json and explore_lite.log. The robot API's /status and /task show the newest line, and self-care's
run_outcomes check reads the summaries (chapter 23).
New idea: learning a body model from practice
Instead of typing speed limits into a config file, the robot tries small moves of its own, measures what
actually happened (did it cover the distance? did it stall?), and turns that experience into a table: for each
kind of move, speed and floor, the share of the commanded distance it got and how often it stalled. That table
is its body model. The owner's rule from 2026-10-02: "stop trying to control the robot and start trying to
train it". Exploring is unlocked only after 60 practice moves: a curriculum, not a switch.
behavior/practice.py is 250 lines; read it in the repo. It starts an AssistedTeleop action that lasts the whole
session, then loops: pick a direction and a speed at random, check the local costmap is free in that direction,
command the move for the time 25 cm would take, measure, then drive back the same way. The choices:
On your laptop:
SECONDS = float(os.environ.get('PRACTICE_SECONDS', '240'))
REACH = 0.25 # m a move tries to cover (short: it practises, it does not travel)
MAX_MOVE_S = 2.5
SPEEDS = (0.08, 0.12, 0.18, 0.25, 0.35)
On your laptop:
DIRECTIONS = (0, 45, -45, 90, -90) # deg in the robot's own frame: 0 = ahead, 90 = left. Never backwards into
# floor it has not just covered: the rear is blind (it backed into a screen
# door on 2026-10-02). Backwards only happens as the return leg of a move.
TURNS = (0.0, 0.0, 0.0, 0.5, -0.5)
CLEAR = 0.55 # m of free costmap needed in the direction of a move (reach + body + margin)
FREE_COST = 50 # costmap cost (0-100) that counts as free for practising
LOW_BATTERY_V = 10.0
REFLEX = 'estop topic'
Before every move it checks three lines across the robot's width, 0.55 m out, on the local costmap:
On your laptop:
def free_ahead(self, deg):
"""Is the costmap free for CLEAR m in this body direction, over the width of the robot?"""
c, p = self.cost, self.odom_pose()
if c is None or p is None:
return False
g = np.array(c.data, np.int16).reshape(c.info.height, c.info.width); r = c.info.resolution
a = p[2] + math.radians(deg)
for d in np.arange(0.0, CLEAR + 1e-6, r):
for off in (-0.14, 0.0, 0.14):
x = p[0] + d * math.cos(a) - off * math.sin(a); y = p[1] + d * math.sin(a) + off * math.cos(a)
i, j = int((x - c.info.origin.position.x) / r), int((y - c.info.origin.position.y) / r)
if not (0 <= i < g.shape[1] and 0 <= j < g.shape[0]) or g[j, i] >= FREE_COST or g[j, i] < 0:
return False
return True
The session loop: 15 % of the time a turn on the spot; otherwise a move out and back. Every 40 refused directions it
turns and looks again.
On your laptop:
while time.time() < t_end:
why = self.st.get('estop') or (self.st.get('plugged') and 'plugged in') or \
((self.battery() or 99) < LOW_BATTERY_V and 'low battery')
if why and not (why == REFLEX and self.recover()):
print(json.dumps(dict(ev='stop', reason=why))); break
if random.random() < 0.15: # a turn on the spot
self.move(k, 0, 0.0, random.choice((0.5, -0.5, 1.0, -1.0)), 'turn'); k += 1
continue
deg = random.choice(DIRECTIONS)
if not self.free_ahead(deg):
skipped += 1; self.spin(0.05)
if skipped % 40 == 0: # nothing free around it: turn and look again
self.move(k, 0, 0.0, random.choice((0.5, -0.5)), 'turn'); k += 1
continue
speed, wz = random.choice(SPEEDS), random.choice(TURNS)
out = self.move(k, deg, speed, wz, 'out'); k += 1
if out['stopped_by'] and not self.recover():
continue
back = deg - 180 if deg > 0 else deg + 180 # then back the way it came (floor it has just covered)
self.move(k, back, speed, -wz, 'back'); k += 1
Each move writes one line to ~/practice/<run>/trials.jsonl: what was asked, what the camera odometry, the LiDAR
odometry, the map pose and the gyro say happened, what stopped it, the floor's colour right before (a crop is saved
as floor_<n>.jpg), and strafe_guard_ok. That last flag exists because the apt nav2_behaviors 1.1.20 checks
the wrong side for sideways moves in AssistedTeleop (upstream bug #6534); the flag is true only when the fixed
overlay from scripts/install_nav2_behaviors_fix.sh (chapter 18) is the one installed.
If it fails: practice that only turns
The one real practice session (2026-10-05 17:05 UTC, started by the mind) made 67 moves and every one was a
turn on the spot.free_ahead()refused every direction 368 times: withCLEAR0.55 m andFREE_COST50
(values typed by hand, not measured) nothing around the robot counted as free. Of the turn commands only 5
reached the wheels in full; 51 were cut by the collision guards and 8 blocked. The trial record does not store
what reached the wheels, so the body model learned "turning gets 10-62 %", which is false. That is the current
state; the thresholds have not been changed.
behavior/learn_body.py reads every trial ever recorded and builds the table. First it names each move by its
kind, and takes the distance covered along the commanded direction from the camera odometry (or the map pose):
On your laptop:
"""The robot's body model, learned from its own practice (behavior/practice.py) - nothing here is set by hand.
Reads every ~/practice/*/trials.jsonl and works out, for each kind of move (ahead, diagonal, sideways, return leg,
turn), each speed, and each kind of floor it has seen (its own grouping of what the floor looked like before the
move): how much of the commanded distance it actually got, and how often it stalled.
Writes ~/practice/body_model.json and prints the table. Usage: python3 learn_body.py"""
import glob, json, math, os, time
import numpy as np
HOME = os.path.expanduser('~/practice')
def kind_of(t):
if t['kind'] == 'turn': return 'turn'
if t['kind'] == 'back': return 'return leg'
d = abs(t['dir_deg'])
return 'ahead' if d < 20 else 'diagonal' if d < 70 else 'sideways'
def measured(t):
"""Best measurement of the distance covered along the commanded direction: camera odometry, else the map pose."""
for k in ('camera', 'map'):
if t.get(k):
return t[k][1], k
return None, None
Then it groups the floor colours (the a and b channels of the Lab colour space, which ignore brightness) into two
kinds with a few rounds of k-means, or one kind if the two groups are close. Nobody names the floors; the robot's
own groups are "floor 0" and "floor 1".
On your laptop:
def floors(trials, k=2, iters=20):
"""Group the floor looks by colour (Lab a,b mean) into k kinds - the robot's own categories, no names given."""
X = np.array([t['floor'][1:3] for t in trials if t.get('floor')], float)
if len(X) < 2 * k:
return None, None
c = X[np.linspace(0, len(X) - 1, k).astype(int)].copy()
for _ in range(iters):
lab = np.argmin(((X[:, None, :] - c[None]) ** 2).sum(-1), 1)
for j in range(k):
if (lab == j).any(): c[j] = X[lab == j].mean(0)
if np.linalg.norm(c[0] - c[1]) < 6.0: # the looks do not really differ: one kind of floor so far
return np.array([X.mean(0)]), np.zeros(len(X), int)
return c, lab
Last, one row per floor, kind of move and speed: the median got/asked ratio, and the stall rate (stopped, or under
15 % of what was asked). For turns the ratio is the gyro's turn against the commanded turn.
On your laptop:
def main():
trials = []
for f in sorted(glob.glob(os.path.join(HOME, '*', 'trials.jsonl'))):
trials += [json.loads(l) for l in open(f) if l.strip().startswith('{"k"')]
if not trials:
print('no practice yet'); return
centers, lab = floors(trials)
it = iter(lab) if lab is not None else None
rows = {}
for t in trials:
fl = int(next(it)) if it is not None and t.get('floor') else 0
if t['kind'] == 'turn':
asked = t['wz'] * t['secs']; got = t.get('gyro_turn', 0.0)
ratio = got / asked if abs(asked) > 1e-3 else None; speed = abs(t['wz'])
else:
got, _ = measured(t); ratio = None if got is None or t['asked_m'] < 1e-3 else got / t['asked_m']; speed = t['speed']
if ratio is None:
continue
stall = bool(t.get('stopped_by')) or ratio < 0.15
rows.setdefault((fl, kind_of(t), speed), []).append((ratio, stall))
model = dict(updated=time.strftime('%Y-%m-%d %H:%M:%S'), trials=len(trials),
floors=None if centers is None else [dict(id=i, lab_ab=[round(float(v), 1) for v in c]) for i, c in enumerate(centers)],
moves=[])
print(f'{len(trials)} practice moves; floor kinds seen: {0 if centers is None else len(centers)}')
print('floor move speed n got/asked (median) stalled')
for (fl, kind, speed), v in sorted(rows.items()):
r = float(np.median([x[0] for x in v])); s = float(np.mean([x[1] for x in v]))
model['moves'].append(dict(floor=fl, move=kind, speed=speed, n=len(v), got_ratio=round(r, 2), stall_rate=round(s, 2)))
print(f' {fl} {kind:11s} {speed:4.2f} {len(v):3d} {r:5.2f} {100 * s:3.0f} %')
json.dump(model, open(os.path.join(HOME, 'body_model.json'), 'w'), indent=1)
if __name__ == '__main__':
main()
The complete file, behavior/learn_body.py:
On your laptop:
"""The robot's body model, learned from its own practice (behavior/practice.py) - nothing here is set by hand.
Reads every ~/practice/*/trials.jsonl and works out, for each kind of move (ahead, diagonal, sideways, return leg,
turn), each speed, and each kind of floor it has seen (its own grouping of what the floor looked like before the
move): how much of the commanded distance it actually got, and how often it stalled.
Writes ~/practice/body_model.json and prints the table. Usage: python3 learn_body.py"""
import glob, json, math, os, time
import numpy as np
HOME = os.path.expanduser('~/practice')
def kind_of(t):
if t['kind'] == 'turn': return 'turn'
if t['kind'] == 'back': return 'return leg'
d = abs(t['dir_deg'])
return 'ahead' if d < 20 else 'diagonal' if d < 70 else 'sideways'
def measured(t):
"""Best measurement of the distance covered along the commanded direction: camera odometry, else the map pose."""
for k in ('camera', 'map'):
if t.get(k):
return t[k][1], k
return None, None
def floors(trials, k=2, iters=20):
"""Group the floor looks by colour (Lab a,b mean) into k kinds - the robot's own categories, no names given."""
X = np.array([t['floor'][1:3] for t in trials if t.get('floor')], float)
if len(X) < 2 * k:
return None, None
c = X[np.linspace(0, len(X) - 1, k).astype(int)].copy()
for _ in range(iters):
lab = np.argmin(((X[:, None, :] - c[None]) ** 2).sum(-1), 1)
for j in range(k):
if (lab == j).any(): c[j] = X[lab == j].mean(0)
if np.linalg.norm(c[0] - c[1]) < 6.0: # the looks do not really differ: one kind of floor so far
return np.array([X.mean(0)]), np.zeros(len(X), int)
return c, lab
def main():
trials = []
for f in sorted(glob.glob(os.path.join(HOME, '*', 'trials.jsonl'))):
trials += [json.loads(l) for l in open(f) if l.strip().startswith('{"k"')]
if not trials:
print('no practice yet'); return
centers, lab = floors(trials)
it = iter(lab) if lab is not None else None
rows = {}
for t in trials:
fl = int(next(it)) if it is not None and t.get('floor') else 0
if t['kind'] == 'turn':
asked = t['wz'] * t['secs']; got = t.get('gyro_turn', 0.0)
ratio = got / asked if abs(asked) > 1e-3 else None; speed = abs(t['wz'])
else:
got, _ = measured(t); ratio = None if got is None or t['asked_m'] < 1e-3 else got / t['asked_m']; speed = t['speed']
if ratio is None:
continue
stall = bool(t.get('stopped_by')) or ratio < 0.15
rows.setdefault((fl, kind_of(t), speed), []).append((ratio, stall))
model = dict(updated=time.strftime('%Y-%m-%d %H:%M:%S'), trials=len(trials),
floors=None if centers is None else [dict(id=i, lab_ab=[round(float(v), 1) for v in c]) for i, c in enumerate(centers)],
moves=[])
print(f'{len(trials)} practice moves; floor kinds seen: {0 if centers is None else len(centers)}')
print('floor move speed n got/asked (median) stalled')
for (fl, kind, speed), v in sorted(rows.items()):
r = float(np.median([x[0] for x in v])); s = float(np.mean([x[1] for x in v]))
model['moves'].append(dict(floor=fl, move=kind, speed=speed, n=len(v), got_ratio=round(r, 2), stall_rate=round(s, 2)))
print(f' {fl} {kind:11s} {speed:4.2f} {len(v):3d} {r:5.2f} {100 * s:3.0f} %')
json.dump(model, open(os.path.join(HOME, 'body_model.json'), 'w'), indent=1)
if __name__ == '__main__':
main()
Check
On a fresh robotpython3 ~/behavior/learn_body.pyprintsno practice yet(2026-10-02). On this robot today
the model holds the one turns-only session (read 2026-10-07 from~/practice/body_model.json):body model: 67 moves, updated 2026-10-05 17:09:45 floors [{'id': 0, 'lab_ab': [134.6, 128.1]}, {'id': 1, 'lab_ab': [141.1, 147.4]}] {'floor': 0, 'move': 'turn', 'speed': 0.5, 'n': 25, 'got_ratio': 0.26, 'stall_rate': 0.48} {'floor': 0, 'move': 'turn', 'speed': 1.0, 'n': 16, 'got_ratio': 0.1, 'stall_rate': 0.62} {'floor': 1, 'move': 'turn', 'speed': 0.5, 'n': 16, 'got_ratio': 0.62, 'stall_rate': 0.06} {'floor': 1, 'move': 'turn', 'speed': 1.0, 'n': 10, 'got_ratio': 0.62, 'stall_rate': 0.2}
ros2/rosorin_base/rosorin_base/learned_limits.py closes the loop: when navigation starts, it reads the body model
and patches Nav2's speed limits. With fewer than 60 moves, or none, it changes nothing and nav2.yaml's values stay
("only the starting point the robot is born with").
On your laptop:
"""Driving limits come from what the robot learned about its own body (~/practice/body_model.json, written by
behavior/learn_body.py from its own practice moves) - not from numbers typed into nav2.yaml.
apply(cfg) patches a loaded nav2.yaml dict in place and returns what it changed (also written to
~/practice/applied_limits.json). With too little practice it changes nothing: nav2.yaml's values are only the starting
point the robot is born with."""
import json, os, time
MODEL = os.path.expanduser('~/practice/body_model.json')
MIN_TRIALS = 60 # practice moves before the model is used at all
MIN_N = 3 # moves of one kind/speed/floor before that row counts
OK_STALL, OK_RATIO = 0.10, 0.60
CEIL = 0.35 # never above the driver's own limit
def works(rows, move):
"""Speeds of this kind of move that work on EVERY floor it has tried them on (enough tries, rarely stalls,
gets most of the distance). Sorted."""
by_speed = {}
for r in rows:
if r['move'] == move and r['n'] >= MIN_N:
by_speed.setdefault(r['speed'], []).append(r['stall_rate'] <= OK_STALL and r['got_ratio'] >= OK_RATIO)
return sorted(s for s, ok in by_speed.items() if all(ok))
A speed "works" for a kind of move only if, on every floor it was tried on at least 3 times, it stalled at most
10 % of the time and got at least 60 % of the distance. apply() then sets the forward limits from the fastest and
slowest working forward speeds, and the sideways limit only if sideways was tried at all (0 if no sideways speed
works). Turn rows are not used.
On your laptop:
def apply(cfg, model_path=MODEL):
try:
m = json.load(open(model_path))
except (OSError, ValueError):
return {'used': False, 'why': 'no body model yet'}
if m.get('trials', 0) < MIN_TRIALS:
return {'used': False, 'why': f"only {m.get('trials', 0)} practice moves (needs {MIN_TRIALS})"}
rows = m.get('moves', [])
f = cfg['controller_server']['ros__parameters']['FollowPath']
vs = cfg['velocity_smoother']['ros__parameters']
out = {'used': True, 'trials': m['trials'], 'changes': {}}
ahead, side = works(rows, 'ahead'), works(rows, 'sideways')
tried_side = any(r['move'] == 'sideways' and r['n'] >= MIN_N for r in rows)
if ahead:
vmax, vmin = min(CEIL, ahead[-1]), ahead[0]
out['changes'].update(max_vel_x=vmax, max_speed_xy=vmax, min_speed_xy=vmin)
f['max_vel_x'], f['max_speed_xy'], f['min_speed_xy'] = vmax, vmax, vmin
vs['max_velocity'][0] = vmax
if tried_side: # it knows how sideways goes on its floors
vy = min(CEIL, side[-1]) if side else 0.0
out['changes'].update(max_vel_y=vy)
f['max_vel_y'], f['min_vel_y'] = vy, -vy
f['vy_samples'] = 5 if vy > 0 else 1
vs['max_velocity'][1], vs['min_velocity'][1] = vy, -vy
out['t'] = time.strftime('%Y-%m-%d %H:%M:%S')
try:
json.dump(out, open(os.path.join(os.path.dirname(model_path), 'applied_limits.json'), 'w'), indent=1)
except OSError:
pass
return out
The complete file, ros2/rosorin_base/rosorin_base/learned_limits.py:
On your laptop:
"""Driving limits come from what the robot learned about its own body (~/practice/body_model.json, written by
behavior/learn_body.py from its own practice moves) - not from numbers typed into nav2.yaml.
apply(cfg) patches a loaded nav2.yaml dict in place and returns what it changed (also written to
~/practice/applied_limits.json). With too little practice it changes nothing: nav2.yaml's values are only the starting
point the robot is born with."""
import json, os, time
MODEL = os.path.expanduser('~/practice/body_model.json')
MIN_TRIALS = 60 # practice moves before the model is used at all
MIN_N = 3 # moves of one kind/speed/floor before that row counts
OK_STALL, OK_RATIO = 0.10, 0.60
CEIL = 0.35 # never above the driver's own limit
def works(rows, move):
"""Speeds of this kind of move that work on EVERY floor it has tried them on (enough tries, rarely stalls,
gets most of the distance). Sorted."""
by_speed = {}
for r in rows:
if r['move'] == move and r['n'] >= MIN_N:
by_speed.setdefault(r['speed'], []).append(r['stall_rate'] <= OK_STALL and r['got_ratio'] >= OK_RATIO)
return sorted(s for s, ok in by_speed.items() if all(ok))
def apply(cfg, model_path=MODEL):
try:
m = json.load(open(model_path))
except (OSError, ValueError):
return {'used': False, 'why': 'no body model yet'}
if m.get('trials', 0) < MIN_TRIALS:
return {'used': False, 'why': f"only {m.get('trials', 0)} practice moves (needs {MIN_TRIALS})"}
rows = m.get('moves', [])
f = cfg['controller_server']['ros__parameters']['FollowPath']
vs = cfg['velocity_smoother']['ros__parameters']
out = {'used': True, 'trials': m['trials'], 'changes': {}}
ahead, side = works(rows, 'ahead'), works(rows, 'sideways')
tried_side = any(r['move'] == 'sideways' and r['n'] >= MIN_N for r in rows)
if ahead:
vmax, vmin = min(CEIL, ahead[-1]), ahead[0]
out['changes'].update(max_vel_x=vmax, max_speed_xy=vmax, min_speed_xy=vmin)
f['max_vel_x'], f['max_speed_xy'], f['min_speed_xy'] = vmax, vmax, vmin
vs['max_velocity'][0] = vmax
if tried_side: # it knows how sideways goes on its floors
vy = min(CEIL, side[-1]) if side else 0.0
out['changes'].update(max_vel_y=vy)
f['max_vel_y'], f['min_vel_y'] = vy, -vy
f['vy_samples'] = 5 if vy > 0 else 1
vs['max_velocity'][1], vs['min_velocity'][1] = vy, -vy
out['t'] = time.strftime('%Y-%m-%d %H:%M:%S')
try:
json.dump(out, open(os.path.join(os.path.dirname(model_path), 'applied_limits.json'), 'w'), indent=1)
except OSError:
pass
return out
launch/nav.launch.py calls it while it builds the parameters for Nav2, so the limits are fixed for the life of
one navigation start:
On your laptop:
cfg = yaml.safe_load(open(params))
from rosorin_base import learned_limits # driving limits the robot learned from its own practice
print('learned limits:', learned_limits.apply(cfg))
On the robot:
journalctl -u rosorin-nav -b --no-pager -o cat | grep "learned limits" | tail -1
cat ~/practice/applied_limits.json
Check
On a fresh robot:learned limits: {'used': False, 'why': 'no body model yet'}(2026-10-02). On this robot
today the 67 turns pass the 60-move gate, so the model is "used", but it holds no forward or sideways rows and
changes nothing (read 2026-10-07):learned limits: {'used': True, 'trials': 67, 'changes': {}, 't': '2026-10-07 17:14:45'}
behavior/come_here.py is 173 lines; read it in the repo. It is built on the older AMCL explorer
behavior/room_explorer.py (its base class supplies the subscriptions, wheel enable, goals and monitors), and it has
five modes, chosen by a command-line flag:
| Flag | Unit | What it does | Moves? |
|---|---|---|---|
--come (default) |
rosorin-task@come |
Find the owner, drive to 0.7 m short of him, facing him. | yes |
--home |
rosorin-task@home |
Drive to the saved home pose, up to 4 tries within 360 s, with escapes or fresh costmaps between. | yes |
--sethome |
rosorin-task@sethome |
Confirm the pose, save it as home in ~/maps/home_pose.json. |
no |
--dry |
by hand | Only the owner fix: bearing and distance. | no |
--locate |
by hand | Localize standing still. | no |
Where the owner is comes from the mind: it is tracking him, so the camera points at him. The camera's yaw relative
to the body (from TF) is the bearing; the LiDAR returns within 6 degrees of that bearing, between 0.25 and 4 m, give
the distance (the 20th percentile, his legs or body):
On your laptop:
def owner_fix(self):
"""(bearing in odom frame, distance) of the owner, or (None, reason)."""
try:
t = self.tf.lookup_transform('base_link', 'camera_link0', rclpy.time.Time())
except Exception as e:
return None, f'no camera TF: {e}'
b = yaw_q(t.transform.rotation)
sc = self.s['scan']; r = np.asarray(sc.ranges, np.float32)
ang = sc.angle_min + np.arange(len(r)) * sc.angle_increment + math.pi
ok = np.isfinite(r) & (r > 0.12)
lx = R.LASER_X + r[ok] * np.cos(ang[ok]); ly = r[ok] * np.sin(ang[ok])
bear = np.arctan2(ly, lx); dist = np.hypot(lx, ly)
sel = (np.abs(np.angle(np.exp(1j * (bear - b)))) < math.radians(6)) & (dist > 0.25) & (dist < 4.0)
if sel.sum() < 2:
return None, f'no LiDAR return toward the owner (bearing {math.degrees(b):.0f} deg)'
d = float(np.percentile(dist[sel], 20))
return (b + self.s['odom_yaw'], d), None
The + math.pi is the LiDAR's mounting: it is turned 180 degrees on the robot (chapter 14). The bearing is stored in
the odometry frame, so it stays right if the robot turns while localizing.
Since 2026-10-06 the task trusts the navigation supervisor's word on localization (chapter 18) and reads its pose
from TF; the older reset-and-spin routine from room_explorer.py is only the fallback. Setting home is then a
standing-still save:
On your laptop:
if '--sethome' in sys.argv:
if not self.localized(): return
x, y, yaw = self.s['pose']
json.dump({'x': x, 'y': y, 'yaw': yaw, 'overlay': round(self.overlay(), 3), 'set': time.time()},
open(HOME, 'w'), indent=1)
self.log('summary', result='SUCCEEDED', home=[round(x, 3), round(y, 3), round(math.degrees(yaw), 1)])
return
One script serves both exploration and practice. Start with the decision it makes first: is there a kept map? If
~/maps/room_live.yaml exists, the run is a client of the always-on navigation. If not (or with REMAP=1), it is a
mapping run that needs the other stack, nav_slam.launch.py (chapter 17), and stops rosorin-nav for its length.
On your laptop:
#!/bin/bash
# One self-run room exploration (rosorin-explore.service) or body practice (PRACTICE=1, rosorin-practice.service):
# kept map (~/maps/room_live.yaml) -> client of the always-on navigation
# no kept map yet, or REMAP=1 -> MAPPING run: slam_toolbox + Nav2 + nvblox, map saved with nav2 map_saver
# then behavior/explore_auto.py drives (explore_lite frontiers, then floor not driven over), back to the start.
# Wheels are disabled on every exit path (explorer finally + nav_stop.sh). Log: ~/explore/<run>/.
source /opt/ros/humble/setup.bash
source /home/burgerbarn/ros2_ws/install/setup.bash
M=/home/burgerbarn/maps
# Navigation is ALWAYS ON (rosorin-nav.service: kept map, AMCL, Nav2, nvblox, kept usable by nav_supervisor). A run is a
# client: it waits for "ready" and never stops navigation. Only a MAPPING run (no kept map yet, or REMAP=1) needs the
# other stack (slam_toolbox): it stops the service, maps, and starts the service again on every exit path.
MAPPING=0
[ "$PRACTICE" = 1 ] && export ROSORIN_PRACTICE=1 # body practice uses the running navigation's behaviour server
{ [ ! -f $M/room_live.yaml ] || [ "$REMAP" = 1 ]; } && MAPPING=1
BAG=/home/burgerbarn/bags/$(date +%Y%m%d_%H%M%S)
Then the exit path. trap finish EXIT runs finish however the script ends: normally, on an error, or when
systemd stops it. It stops the recording and keeps the six newest, puts the arm's pan setting back, stops
explore_lite, disables the wheels, restarts navigation after a mapping run, and starts one self-care round. At the
start, background work that competes for the CPU is stopped:
On your laptop:
finish() {
[ -n "$BAGPID" ] && { kill -INT $BAGPID 2>/dev/null; wait $BAGPID 2>/dev/null; }
ls -dt /home/burgerbarn/bags/*/ 2>/dev/null | tail -n +7 | while read d; do rm -rf "$d"; done # keep the 6 newest runs
ros2 param set /rosorin_board_driver drive_pan -- -1 >/dev/null 2>&1 # back to the owner's setting (pan stays put)
pkill -INT -f 'explore_lite/explore' 2>/dev/null
timeout 10 ros2 service call /rosorin_board_driver/enable std_srvs/srv/SetBool "{data: false}" >/dev/null 2>&1
if [ $MAPPING = 1 ]; then bash /home/burgerbarn/setup/nav_stop.sh; sudo systemctl start --no-block rosorin-nav; fi
sudo systemctl start --no-block rosorin-selfcheck # after every drive the robot looks at itself (selfcare/)
}
trap finish EXIT
# background self-work yields to driving (2026-10-05: face training started at boot and ran through the first floor run)
sudo systemctl stop --no-block rosorin-selfcheck rosorin-owner-faces rosorin-reappear 2>/dev/null
Then navigation: camera straight ahead (drive_pan 500) so the depth camera maps the floor it drives onto; for a
mapping run, stop the always-on stack and launch the mapping stack, waiting up to 60 s for Nav2's action; otherwise
wait up to 240 s for the supervisor to say "ready" (nav_wait, chapter 18).
On your laptop:
# camera straight ahead while navigating: the depth camera maps the floor the robot drives onto (PLAN M1)
ros2 param set /rosorin_board_driver drive_pan 500 >/dev/null 2>&1
if [ $MAPPING = 1 ]; then
sudo systemctl stop rosorin-nav
ros2 launch rosorin_base nav_slam.launch.py > /tmp/explore_nav.log 2>&1 &
for i in $(seq 1 60); do ros2 action list 2>/dev/null | grep -q '^/navigate_to_pose$' && break; sleep 1; done
ros2 action list | grep -q '^/navigate_to_pose$' || { echo "nav2 did not come up"; exit 1; }
sleep 8 # lifecycle: let costmaps publish
else
systemctl is-active --quiet rosorin-nav || sudo systemctl start rosorin-nav
ros2 run rosorin_base nav_wait 240 || { echo "navigation is not ready - not driving"; exit 1; }
fi
Every run is recorded with ros2 bag (zstd-compressed, split at 1.5 GB): sensors, commands, odometry, the
driver's state, plans and costmaps, so a failed run can be replayed off the floor later. Then the work itself, with
the vision venv (numpy, OpenCV); practice is followed by learn_body.py with the system Python.
On your laptop:
# record the run (built-in ros2 bag): what it sensed, commanded and measured - to replay failures off the floor and as
# the robot's own training data (docs/research/training_the_body.md step 0)
mkdir -p /home/burgerbarn/bags
ros2 bag record -o $BAG --compression-mode file --compression-format zstd --max-bag-size 1500000000 \
/scan /imu/data_raw /battery /tf /tf_static /joint_states \
/aurora/rgb/image_raw /aurora/rgb/camera_info /aurora/depth/image_raw \
/cmd_vel /cmd_vel_nav /wheel/odom /odometry/filtered /odom_vslam /odom_rf2o /estop /rosorin_board_driver/state \
/rosorin_board_driver/arm_busy \
/plan /local_plan /map /amcl_pose /local_costmap/costmap /global_costmap/costmap /nvblox_node/static_map_slice \
> /tmp/explore_bag.log 2>&1 &
BAGPID=$!
source /home/burgerbarn/vision/venv/bin/activate
if [ "$PRACTICE" = 1 ]; then
python /home/burgerbarn/behavior/practice.py; /usr/bin/python3 /home/burgerbarn/behavior/learn_body.py; RC=3
else
python /home/burgerbarn/behavior/explore_auto.py "$@"; RC=$?
fi
deactivate
Last, a mapping run keeps its new map only if the explorer exited 0 (it got home); the old map goes to history:
On your laptop:
# keep the new map only from a run that finished and got home (a run cut short leaves a partial map); old one -> history
if [ $MAPPING = 1 ] && [ $RC = 0 ]; then
[ -f $M/room_live.yaml ] && { H=$M/history/$(date +%Y%m%d_%H%M%S); mkdir -p $H && mv $M/room_live.* $H/; }
ros2 run nav2_map_server map_saver_cli --fmt png -f $M/room_live 2>&1 | tail -1
ls -la $M/room_live.*
fi
exit $RC
The complete file, scripts/explore_run.sh:
On your laptop:
#!/bin/bash
# One self-run room exploration (rosorin-explore.service) or body practice (PRACTICE=1, rosorin-practice.service):
# kept map (~/maps/room_live.yaml) -> client of the always-on navigation
# no kept map yet, or REMAP=1 -> MAPPING run: slam_toolbox + Nav2 + nvblox, map saved with nav2 map_saver
# then behavior/explore_auto.py drives (explore_lite frontiers, then floor not driven over), back to the start.
# Wheels are disabled on every exit path (explorer finally + nav_stop.sh). Log: ~/explore/<run>/.
source /opt/ros/humble/setup.bash
source /home/burgerbarn/ros2_ws/install/setup.bash
M=/home/burgerbarn/maps
# Navigation is ALWAYS ON (rosorin-nav.service: kept map, AMCL, Nav2, nvblox, kept usable by nav_supervisor). A run is a
# client: it waits for "ready" and never stops navigation. Only a MAPPING run (no kept map yet, or REMAP=1) needs the
# other stack (slam_toolbox): it stops the service, maps, and starts the service again on every exit path.
MAPPING=0
[ "$PRACTICE" = 1 ] && export ROSORIN_PRACTICE=1 # body practice uses the running navigation's behaviour server
{ [ ! -f $M/room_live.yaml ] || [ "$REMAP" = 1 ]; } && MAPPING=1
BAG=/home/burgerbarn/bags/$(date +%Y%m%d_%H%M%S)
finish() {
[ -n "$BAGPID" ] && { kill -INT $BAGPID 2>/dev/null; wait $BAGPID 2>/dev/null; }
ls -dt /home/burgerbarn/bags/*/ 2>/dev/null | tail -n +7 | while read d; do rm -rf "$d"; done # keep the 6 newest runs
ros2 param set /rosorin_board_driver drive_pan -- -1 >/dev/null 2>&1 # back to the owner's setting (pan stays put)
pkill -INT -f 'explore_lite/explore' 2>/dev/null
timeout 10 ros2 service call /rosorin_board_driver/enable std_srvs/srv/SetBool "{data: false}" >/dev/null 2>&1
if [ $MAPPING = 1 ]; then bash /home/burgerbarn/setup/nav_stop.sh; sudo systemctl start --no-block rosorin-nav; fi
sudo systemctl start --no-block rosorin-selfcheck # after every drive the robot looks at itself (selfcare/)
}
trap finish EXIT
# background self-work yields to driving (2026-10-05: face training started at boot and ran through the first floor run)
sudo systemctl stop --no-block rosorin-selfcheck rosorin-owner-faces rosorin-reappear 2>/dev/null
# camera straight ahead while navigating: the depth camera maps the floor the robot drives onto (PLAN M1)
ros2 param set /rosorin_board_driver drive_pan 500 >/dev/null 2>&1
if [ $MAPPING = 1 ]; then
sudo systemctl stop rosorin-nav
ros2 launch rosorin_base nav_slam.launch.py > /tmp/explore_nav.log 2>&1 &
for i in $(seq 1 60); do ros2 action list 2>/dev/null | grep -q '^/navigate_to_pose$' && break; sleep 1; done
ros2 action list | grep -q '^/navigate_to_pose$' || { echo "nav2 did not come up"; exit 1; }
sleep 8 # lifecycle: let costmaps publish
else
systemctl is-active --quiet rosorin-nav || sudo systemctl start rosorin-nav
ros2 run rosorin_base nav_wait 240 || { echo "navigation is not ready - not driving"; exit 1; }
fi
# record the run (built-in ros2 bag): what it sensed, commanded and measured - to replay failures off the floor and as
# the robot's own training data (docs/research/training_the_body.md step 0)
mkdir -p /home/burgerbarn/bags
ros2 bag record -o $BAG --compression-mode file --compression-format zstd --max-bag-size 1500000000 \
/scan /imu/data_raw /battery /tf /tf_static /joint_states \
/aurora/rgb/image_raw /aurora/rgb/camera_info /aurora/depth/image_raw \
/cmd_vel /cmd_vel_nav /wheel/odom /odometry/filtered /odom_vslam /odom_rf2o /estop /rosorin_board_driver/state \
/rosorin_board_driver/arm_busy \
/plan /local_plan /map /amcl_pose /local_costmap/costmap /global_costmap/costmap /nvblox_node/static_map_slice \
> /tmp/explore_bag.log 2>&1 &
BAGPID=$!
source /home/burgerbarn/vision/venv/bin/activate
if [ "$PRACTICE" = 1 ]; then
python /home/burgerbarn/behavior/practice.py; /usr/bin/python3 /home/burgerbarn/behavior/learn_body.py; RC=3
else
python /home/burgerbarn/behavior/explore_auto.py "$@"; RC=$?
fi
deactivate
# keep the new map only from a run that finished and got home (a run cut short leaves a partial map); old one -> history
if [ $MAPPING = 1 ] && [ $RC = 0 ]; then
[ -f $M/room_live.yaml ] && { H=$M/history/$(date +%Y%m%d_%H%M%S); mkdir -p $H && mv $M/room_live.* $H/; }
ros2 run nav2_map_server map_saver_cli --fmt png -f $M/room_live 2>&1 | tail -1
ls -la $M/room_live.*
fi
exit $RC
Safety
On a robot without~/maps/room_live.yaml, starting exploration or practice is a mapping run: it stops
the always-on navigation, maps from scratch, and has no keepout zone, so with a door open it can leave the room.
On 2026-10-05 such a run drove 9 m from its start into space it had never seen. Close the doors, or build the
kept map first (chapter 17).
The task wrapper is shorter: wait for navigation, never stop it, disable the wheels on exit.
On your laptop:
#!/bin/bash
# "Come check this out" (rosorin-task@come): client of the always-on navigation -> behavior/come_here.py.
# Wheels are disabled on every exit path (explorer finally + nav_stop.sh). Log: ~/explore/<run>/.
source /opt/ros/humble/setup.bash
source /home/burgerbarn/ros2_ws/install/setup.bash
# navigation is always on (rosorin-nav.service): wait until it is ready, never stop it
trap 'timeout 10 ros2 service call /rosorin_board_driver/enable std_srvs/srv/SetBool "{data: false}" >/dev/null 2>&1' EXIT
systemctl is-active --quiet rosorin-nav || sudo systemctl start rosorin-nav
ros2 run rosorin_base nav_wait 240 || { echo "navigation is not ready - not driving"; exit 1; }
source /home/burgerbarn/vision/venv/bin/activate
python /home/burgerbarn/behavior/come_here.py "$@"
New idea: template units
A unit file whose name ends in@before.serviceis a template.rosorin-task@.serviceis one file;
systemctl start rosorin-task@homestarts an instance of it and putshomewherever the file says%i. So
one file givesrosorin-task@come,rosorin-task@homeandrosorin-task@sethome.
systemd/rosorin-explore.service:
On your laptop:
[Unit]
Description=ROSOrin explores its room by itself (Nav2 + AMCL, static map; one run per start) - behavior/room_explorer.py
After=rosorin-base.service
Requires=rosorin-base.service
[Service]
Type=simple
User=burgerbarn
Environment=ROS_DOMAIN_ID=0
ExecStart=/bin/bash /home/burgerbarn/setup/explore_run.sh
ExecStopPost=/bin/bash -c 'source /opt/ros/humble/setup.bash && timeout 10 ros2 service call /rosorin_board_driver/enable std_srvs/srv/SetBool "{data: false}"'
KillSignal=SIGINT
TimeoutStopSec=30
Restart=no
# not enabled at boot: the robot cannot tell plugged-in/on-stand from on-the-floor (docs/hardware.md); owner starts a run
Its Description is out of date: since 2026-10-02 the script runs explore_auto.py with explore_lite on the
live map, not room_explorer.py with AMCL on a static map. systemd/rosorin-practice.service is the same unit with
Environment=PRACTICE=1:
On your laptop:
[Unit]
Description=ROSOrin practises with its own body and updates its body model (behavior/practice.py + learn_body.py; one session per start)
After=rosorin-base.service
Requires=rosorin-base.service
[Service]
Type=simple
User=burgerbarn
Environment=ROS_DOMAIN_ID=0
Environment=PRACTICE=1
ExecStart=/bin/bash /home/burgerbarn/setup/explore_run.sh
ExecStopPost=/bin/bash -c 'source /opt/ros/humble/setup.bash && timeout 10 ros2 service call /rosorin_board_driver/enable std_srvs/srv/SetBool "{data: false}"'
KillSignal=SIGINT
TimeoutStopSec=30
Restart=no
systemd/rosorin-task@.service:
On your laptop:
[Unit]
Description=ROSOrin nav task %i (come | home | sethome) - behavior/come_here.py --%i
After=rosorin-base.service
Requires=rosorin-base.service
[Service]
Type=simple
User=burgerbarn
Environment=ROS_DOMAIN_ID=0
ExecStart=/bin/bash /home/burgerbarn/setup/come_run.sh --%i
ExecStopPost=/bin/bash -c 'source /opt/ros/humble/setup.bash && timeout 10 ros2 service call /rosorin_board_driver/enable std_srvs/srv/SetBool "{data: false}"'
KillSignal=SIGINT
TimeoutStopSec=30
Restart=no
# not enabled at boot: started by Buddy (robot_api POST /task/<mode>)
Read the four shared lines as the safety design: Requires=rosorin-base (no driver, no run); KillSignal=SIGINT
(a stop is a Ctrl-C, so the Python finally blocks and the script's trap run); ExecStopPost disables the wheels
through the driver once more after the process is gone, however it ended; Restart=no (a failed run is not
repeated by itself). None has an [Install] section, so none can be enabled at boot: systemctl is-enabled says
static.
The explorer and the task template have install scripts; practice has none. All of them read the unit files from
~/setup, which nothing in the repo fills, so copy the files there first, along with the behaviour code:
On your laptop:
cd ~/CCode/rosorin-pro
ssh rosorin-wifi 'mkdir -p ~/behavior ~/setup'
scp -q behavior/*.py rosorin-wifi:behavior/
scp -q scripts/explore_run.sh scripts/come_run.sh scripts/nav_stop.sh scripts/install_explore_service.sh scripts/rollback_explore_service.sh scripts/install_task_service.sh scripts/rollback_task_service.sh rosorin-wifi:setup/
scp -q systemd/rosorin-explore.service systemd/rosorin-practice.service "systemd/rosorin-task@.service" rosorin-wifi:setup/
scripts/ops/deploy.sh explore (chapter 29) later copies behavior/*.py only.
On the robot:
sudo bash ~/setup/install_explore_service.sh
sudo install -m 0644 ~/setup/rosorin-practice.service /etc/systemd/system/ && sudo systemctl daemon-reload
sudo bash ~/setup/install_task_service.sh
systemctl is-enabled rosorin-explore rosorin-practice rosorin-task@come | paste -sd" "
systemctl cat rosorin-practice | grep -c PRACTICE=1
install_task_service.sh also stops and removes rosorin-come.service, the single-purpose unit it replaced on
2026-10-01; on a fresh robot there is nothing to remove. The practice unit was installed on 2026-10-02 with
sudo cp /tmp/rosorin-practice.service /etc/systemd/system/; install -m 0644 does the same and sets the mode.
Check
All three are installed and none starts at boot (read from the robot 2026-10-07), and the practice unit
carries its switch (2026-10-02):static static static 1
The mind's thinker (chapter 21) has two wheel skills on its menu, practice_moving and explore_room. Choosing one
does not move anything; the mind first works out whether it may drive now, from facts it reads itself:
On your laptop:
BODY_SKILLS = {'practice_moving': 'rosorin-practice', 'explore_room': 'rosorin-explore'} # its own wheel skills
BODY_EVERY, BODY_MIN_V, READY_MOVES = 900.0, 10.6, 60 # s between wheel skills, battery floor, practice before exploring
On your laptop:
can = (not st.get('plugged')) and st.get('estop') is None and (b is None or b >= BODY_MIN_V) and \
now >= self.stay_until and now >= self.body_next and self.body is None and nav_ready and \
not self.wheel_skill_running
| Rule | Where the fact comes from |
|---|---|
| Not plugged in | driver state plugged |
| No e-stop latched | driver state estop |
| Battery at least 10.6 V (or no reading yet) | /battery |
| The owner has not said stop, stay, halt or freeze in the last 30 min | Buddy's /heard speech events |
| At least 900 s since its last wheel skill | its own clock |
| No skill of its own still running | its own record |
| Navigation ready, status younger than 30 s | /nav/status |
| No wheel unit active (practice, explore, any task) | systemctl is-active, every 5 s |
| Exploring only: at least 60 practice moves | ~/practice/body_model.json trials |
The last-but-one rule was added on 2026-10-06 so a mind that resumes after a pause never starts a second wheel skill
while one is running. When everything holds, it releases the arm and starts the unit:
On your laptop:
def start_body_skill(self, skill, now, st, why):
"""The robot starts one of its own wheel skills (its own service; the mind hands over the arm and waits).
Refused, with the reason logged, unless it can drive now; exploring also needs its body practice done."""
k = self.self_knowledge(now, st)
if not k['can_drive'] or (skill == 'explore_room' and not k['ready_to_explore']):
self.event('body_skill_refused', skill=skill, state={x: k[x] for x in ('can_drive', 'plugged', 'battery_v', 'ready_to_explore', 'owner_said_stay')}); return False
unit = BODY_SKILLS[skill]
if self.have_arm:
self.release_arm()
r = subprocess.run(['sudo', 'systemctl', 'start', '--no-block', unit], capture_output=True, text=True)
self.body = {'skill': skill, 'unit': unit, 't0': now}
self.body_next = now + BODY_EVERY
self.event('body_skill_started', skill=skill, why=why, ok=r.returncode == 0)
return r.returncode == 0
Every refusal and every start is an event in ~/vision/mind/<UTC day>/events.jsonl:
On the robot:
grep -h "body_skill" ~/vision/mind/*/events.jsonl | tail -5 | cut -c1-200
rosorin-practice and rosorin-explore and will not start a wheel skill for 30 minutes.rosorin-task@come,@home, @sethome (POST /task/..., chapter 22); Buddy's stop tool calls POST /stop, which stops the threerosorin-explore.Read from the code, not tested together: the mind's stop words do not stop a rosorin-task@ unit, and POST /stop
does not stop rosorin-practice; only the two paths together cover all four units.
Safety
- Never start a wheel unit while another may be starting. Check
systemctl is-active rosorin-explore rosorin-practice 'rosorin-task@*'and the mind's lastbody_skill_startedfirst: a task's exit disables the
wheels under whatever else is driving (lesson of 2026-10-06).- "Plugged in" is a belief from one voltage step of about 190 mV at the moment the charger goes in or out; a
full pack has unplugged with a smaller step (-176 mV seen), and~/battery/plugged.jsonsurvives restarts, so
a missed step persists. Before a stand test, read the driver'spluggedvalue; do not assume it.- The first drive of every new skill happens on the stand, then on the floor with you next to the robot and the
gamepad in your hand.
With the robot on the stand and the charger in, check that the driver agrees it is plugged in, then start an
exploration. It must refuse to drive and clean up.
On the robot:
source /opt/ros/humble/setup.bash
timeout 5 ros2 topic echo --once /rosorin_board_driver/state --field data | grep -o '"plugged": [a-z]*\|"enabled": [a-z]*'
sudo systemctl start rosorin-explore
sleep 60
journalctl -u rosorin-explore -b --no-pager -o cat | grep '"ev"' | tail -3 | cut -c1-200
systemctl is-active rosorin-explore
Check
The run aborts withplugged in, the wheels are switched off again, and the unit is inactive. The same script
on the stand on 2026-10-02 (then still a mapping run):exit 0 after 28 s {"reason": "plugged in", "t": 1790925368.65, "ev": "abort"} {"on": false, "ok": true, "t": 1790925368.74, "ev": "wheels"} nav2 stoppedThrough the unit and with a kept map the ending is the same; the waiting time before it depends on how soon
navigation reports ready.
Then the owner fix, which moves nothing: stand in front of the robot where the mind is tracking you (its head
follows you), and run the come task in dry mode by hand.
On the robot:
source /opt/ros/humble/setup.bash; source ~/ros2_ws/install/setup.bash; source ~/vision/venv/bin/activate
cd ~/behavior && timeout 90 python come_here.py --dry 2>&1 | grep -E '"ev": "(owner|abort|summary)"' | cut -c1-200
Check
The task pauses the mind, finds you by the camera's bearing and the LiDAR, and stops. From 2026-10-01, the
owner 1.44 m away, 15 degrees to the right:{"bearing_deg": -15, "dist_m": 1.44, "t": 1790885464.9, "ev": "owner"} {"result": "DRY", "owner_dist_m": 1.44, "t": 1790885464.9, "ev": "summary"}
If it fails
{"reason": "missing inputs", "have": ["map", "cost", "scan", "v", "drv"], ...}: no heading from
/odometry/filtered. On 2026-10-07 every "come here" by voice aborted like this: an edit on 2026-10-06 had
swallowed the subscription line inCome.__init__(it ended up after areturn). Fixed 2026-10-07 00:55
UTC. After editing a class with a script, read the whole__init__back and run--dry.{"reason": "localization overlay < 0.65", ...}with navigation ready: the task never received a pose.
room_explorer.pysubscribed to the pose with TRANSIENT_LOCAL, and slam_toolbox publishes it VOLATILE, so
nothing matched (2026-10-06 22:30). The task now trusts/nav/statusand reads the pose from TF.no LiDAR return toward the owner: the mind is not looking at you, or you stand closer than 0.25 m or
farther than 4 m.
Buddy starts the tasks through the API (chapter 22). By hand, in bash on bigbuddy:
On bigbuddy:
T=$(cat ~/.config/rosorin/api_token); R=http://192.168.1.108:8296
curl -s -m 5 -X POST -H "X-Robot-Token: $T" $R/task/home; echo
curl -s -m 5 -H "X-Robot-Token: $T" $R/task; echo
Check
A start answers{"started": true, "error": ""}(2026-10-01); a start while something drives answers
{"started": false, "error": "already driving"}(2026-10-01). The home task's summary on 2026-10-06 22:33 UTC,
from the spot where an exploration had left the robot stranded against a bean bag:{"result": "SUCCEEDED", "stopped_by": null, "tries": 1, "t": 1791326002.19, "ev": "summary"}
After a floor run, read its summary and its log:
On the robot:
L=$(ls -td ~/explore/*/ | head -1); echo $L
grep -h "summary\|abort\|look_around\|escape\|second_wind\|home_try" $L/log.jsonl | cut -c1-230 | tail -6
Check
A run that stopped for its first look-around logs it like this (2026-10-06 22:35 UTC): the wheels went off,
the mind ran a room scan, the wheels came back.{"scans": 1, "started": true, "secs": 22.8, "wheels": true, "pose": [0.08, 0.13, 53], "t": 1791326111.54, "ev": "look_around"}
Every floor run in the record, oldest first. "Started by" says who decided to drive: "by hand" means the owner, or
Claude with the owner's permission, started it with a command; "the mind" means the robot chose it.
| When (UTC) | Skill | Started by | Result |
|---|---|---|---|
| 2026-10-01 | explore (older room_explorer.py, AMCL) |
by hand | 5 goals, then tried to leave through the open door; froze at the closed door; 18 % covered, ended 2.67 m from home |
| 2026-10-02 | explore (explore_auto.py, mapping) |
by hand | Run 1: 1.75 m, 2 goals, home within 8 cm. Runs 2 and 3: stopped by the contact reflex at the rug edge |
| 2026-10-05 15:21 | explore | the owner | 3.9 m, then five goals in a row failed at 50 s (controller overloaded) |
| 2026-10-05 16:19 | explore (mapping) | the owner | 39.8 m in 230 s, 3 goals reached, 2 aborted, no contact; left the room and drove 9 m into unseen space; stopped by hand, map not saved |
| 2026-10-05 17:05 | practice | the mind | 67 moves, all turns on the spot (see above) |
| 2026-10-05 17:25 | explore | the mind | 0.27 m, one goal aborted, "no reachable floor left" after 24 s, going home timed out (navigation had no pose) |
| 2026-10-05 23:24, 23:34 | explore (always-on navigation) | by hand | 5.7 m and one goal, then every goal aborted near chair legs and a cable; second run drove out of that spot, 3.6 m |
| 2026-10-06 19:52 | explore | the mind | Stopped by hand at 19:58 and sent home; result not recorded |
| 2026-10-06 22:13 | explore | not recorded | 8 goals aborted, going home aborted; wedged against a bean bag. Led to unstick.py, second winds, 4 home tries |
| 2026-10-06 22:33 | home | by hand | SUCCEEDED in 1 try from the stranded spot |
| 2026-10-07 00:38 | come | owner, by voice | Aborted "missing inputs" (the edit bug above); fixed, not driven since |
Not done: what the record does not show
- No run has done the whole job. No exploration has covered the room with the live map, come home, and
kept the updated map. Continued mapping on the floor is "NOT yet verified: a real drive" (decisions,
2026-10-06).- The robot choosing to drive has never gone well. The mind has started wheel skills by itself three times
(practice and explore on 2026-10-05, explore on 2026-10-06); none produced a useful result, and the outcome of
practice never reached the mind (result: null).- Practice taught nothing true. All 67 practice moves were turns, and the 60-move count still passed it as
"ready to explore". The learned limits load on every navigation start and change nothing.- "Come to me" with the current code has been checked only for its inputs (2026-10-07), not driven.
- 24 hours unattended has never been attempted (chapter 31).
ls ~/ros2_ws/install/explore_lite/lib/explore_lite lists explore.systemctl is-enabled rosorin-explore rosorin-practice rosorin-task@come prints static static static.sudo systemctl start rosorin-explore ends with "reason": "plugged in" andcome_here.py --dry reports your bearing and distance while the mind tracks you.journalctl -u rosorin-nav -b | grep "learned limits" shows what the body model did to the speed limits.Where this comes from
behavior/explore_auto.py,behavior/practice.py,behavior/learn_body.py,behavior/come_here.py,
behavior/room_explorer.py(base class),behavior/unstick.py,ros2/rosorin_base/rosorin_base/learned_limits.py,
ros2/rosorin_base/launch/nav.launch.pylines 46-48,ros2/rosorin_base/config/explore.yaml,
scripts/explore_run.sh,scripts/come_run.sh,scripts/install_explore_service.sh,
scripts/install_task_service.sh,systemd/rosorin-explore.service,systemd/rosorin-practice.service,
systemd/rosorin-task@.service,vision/mind.py(lines 62-63, 513-585),buddy_link/robot_api.py(/task,
/stop), repo HEAD bfb61d8.docs/decisions.md2026-10-01 (come, home), 2026-10-02 (explore_lite, delegation,
curriculum), 2026-10-06 (real drive not verified);docs/status.md2026-10-02, 2026-10-05, 2026-10-06 late;
docs/lessons.md2026-10-01 (door), 2026-10-02 (rug edge, AssistedTeleop sideways bug), 2026-10-06 (wheel tasks,
plug state, volatile pose, swallowed__init__line);docs/research/whole_project_review.mdfindings 3, 4, 30.
sources/survey_live_robot.md(m-explore-ros2 @ 326cf8a, COLCON_IGNORE by hand, practice unit not scripted),
sources/gaps_from_cmdlog.mdsection 6. Command logcmdlog_robot.md: explore_lite build 2026-10-02T07:11:10 and
07:11:25, stand test 07:15:40, practice unit and learned limits 12:31:42, come dry run 2026-10-01T20:08:53, home
22:33:01 and look-around 22:35 on 2026-10-06, come abort 2026-10-07T00:38:48 and 00:53:48;cmdlog_bigbuddy.md
/task starts 2026-10-01T20:19:57 and 20:20:53. Read-only lookups on the robot 2026-10-07 (body model,
applied limits, unit states).