roostField / Lab
Curriculum
Phase 01Lesson 5 of 5
85 min
Instrument the robotWeeks 1–2

Lab 0: bring-up, safety, and the episode logger

Four lessons of theory become one working station: a layered safety stack you have actually tripped on purpose, a reset you can repeat in fifteen seconds, and an episode logger that turns every run into replayable data. Passing this lab is the exit gate for Phase 01.

After this lesson you can
  • Build a layered safety stack — firmware limits, command validator, stale-command watchdog, workspace box, e-stop — and name the failure each layer catches.
  • Size the watchdog timeout and the teleop speed cap from a worst-case-travel budget in millimeters, not from a guess.
  • Implement an episode logger that records observations, commands, acknowledgements, and faults on one monotonic clock, with a schema you can defend.
  • Pass the phase exit gate: a two-minute teleop recording, a deterministic replay, and a second engineer reproducing the setup from your repository alone.

Everything in this phase so far has been study; today you build. You have done this week before: racking a new node, flashing firmware, wiring monitoring, writing the runbook — the shift that turns hardware into infrastructure. Lab 0 is that shift for your robot station. The previous four lessons each handed you one layer: what the actuators do when commanded, a frame tree that makes where unambiguous, one clock that makes when unambiguous, and a transport with explicit QoS contracts. Today you assemble them into a station that is safe, resettable, and observable to the millisecond — before any learned policy touches it.

The deliverables double as this module's evidence: a hardware setup guide someone else can follow, the frame diagram, a timestamp schema, the baseline latency report — and, new today, a tested safety stack plus a working episode logger with one validated recording. Budget the hour like a bring-up shift: half on safety and reset, half on the logger and its validation.

Safety before autonomy: layers, not vigilance

The dangerous phase of lab robotics is not day one, when you watch every move. It is week three, when familiarity has replaced attention and a policy is generating commands no human previewed. You cannot fix that with vigilance; you fix it with layers. The stakes, honestly: a WidowX AI (0.77 m reach, 1.5 kg payload, 27 N·m shoulder-class joints, 100 N gripper) is no industrial arm, but it is a fast 4 kg machine that can bruise a hand, pinch a finger hard, sweep its own wrist camera into the table, or shove a fixture off the bench before you can react. Safety here protects fingers first, then hardware, calibration, and datasets. The layers, from lowest latency and least context to highest:

  • Controller firmware limits — joint position range, velocity and effort ceilings, temperature shutdown, enforced in the arm's iNerve controller below your host. Millisecond latency, alive even when your host is dead. The driver exposes the limit configuration; set it explicitly.
  • Command validator — one choke point every command passes through: reject NaN and out-of-range targets, clamp velocity and acceleration. Acts within one control tick.
  • Stale-command watchdog — if the command stream ages past a timeout, trigger the safe-stop path. Catches dead or wedged upstream processes.
  • Workspace box — a Cartesian bounding volume (say 500 × 400 × 350 mm above the table) checked against the commanded target's forward kinematics. Catches fresh, well-formed commands aimed somewhere terrible.
  • Hardware e-stop — a physical button reachable without moving your feet, tested at the start of every session.
  • Human procedure — never reach into the workspace while torque is enabled; a printed checklist by the arm.

Foundations: Time, Kinematics, and Control Modes

Before assembling the safety stack, we must rigorously define the physical and temporal primitives that the subsequent sections rely upon. The primary distinction is between the Control Loop and the Command Stream. The Control Loop is a local, deterministic process running at a fixed frequency (e.g., 100 Hz) that owns the direct connection to the robot's actuators. It is the heartbeat of the system. The Command Stream is an external, asynchronous input (from teleop or a policy) that may arrive at variable rates. The watchdog logic exists specifically to monitor the age of the Command Stream relative to the deterministic ticks of the Control Loop. If the stream stalls, the loop must act independently.

The second primitive is Forward Kinematics (FK). FK is the mathematical mapping from joint angles qRnq \in \mathbb{R}^n to the Cartesian position xR3x \in \mathbb{R}^3 of the end-effector. The 'Workspace Box' safety layer operates in Cartesian space, but the robot is controlled in joint space. Therefore, every control tick requires solving FK to verify that the commanded joint target qcmdq_{cmd} results in a Cartesian position xcmdx_{cmd} within the safe bounds. This is a computational cost that must be accounted for in the loop's timing budget.

The third primitive is the distinction between Position Mode and Velocity Mode control. In Position Mode, the controller drives the joints toward a specific target angle qtargetq_{target}. If the command stream dies, the 'safe-stop' latches the last valid qtargetq_{target}, holding the arm in place. In Velocity Mode, the controller drives the joints at a specific rate vv. If the stream dies, the arm continues moving at vv until the watchdog triggers a deceleration. This distinction is critical: staleness in Position Mode is generally benign (the arm stops), while staleness in Velocity Mode is dangerous (the arm runs away).

Finally, we define Jitter. Jitter is the standard deviation of the time interval between consecutive control loop iterations. In an ideal system, the interval is constant (TctrlT_{ctrl}). In practice, CPU contention, I/O blocking, or garbage collection can cause the interval to vary. High jitter destabilizes the control system and can cause the watchdog to miss a timeout if a tick is delayed. The exit gate requires that the 99th percentile of tick intervals remains within a strict budget (e.g., < 2x nominal).

PropertyPosition ModeVelocity Mode
InputTarget angle qtargetq_{target}Target velocity vtargetv_{target}
Staleness BehaviorHolds last qtargetq_{target} (Safe)Continues at vtargetv_{target} (Dangerous)
Safe-Stop ActionLatch current qqDecelerate to v=0v=0
Typical UsePick-and-place, precise positioningTeleop, dynamic tracking
Control Mode Comparison

Worked Example: Worst-Case Travel Distance

We now calculate the worst-case travel distance for a robot arm in Velocity Mode when the command stream fails. This calculation determines the maximum speed cap and watchdog timeout for a given safety margin. We assume the arm is moving at a constant velocity vv when the stream dies. The total travel distance dworstd_{worst} is the sum of the distance traveled during the detection phase and the distance traveled during the braking phase.

dworst=v(Twd+Tctrl)+v22ad_{\text{worst}} = v(T_{wd} + T_{ctrl}) + \frac{v^2}{2a}
Worst-case travel distance: detection phase plus braking phase.

Given: v=0.3v = 0.3 m/s, Twd=0.2T_{wd} = 0.2 s, Tctrl=0.01T_{ctrl} = 0.01 s, a=2a = 2 m/s². The detection time is Tdetect=Twd+Tctrl=0.21T_{detect} = T_{wd} + T_{ctrl} = 0.21 s. The distance during detection is ddetect=v×Tdetect=0.3×0.21=0.063d_{detect} = v \times T_{detect} = 0.3 \times 0.21 = 0.063 m. The braking distance is dbrake=v22a=0.094=0.0225d_{brake} = \frac{v^2}{2a} = \frac{0.09}{4} = 0.0225 m. The total worst-case travel is dworst=0.063+0.0225=0.0855d_{worst} = 0.063 + 0.0225 = 0.0855 m, or 85.5 mm. If the object being manipulated is smaller than 85.5 mm, the arm will overshoot it before stopping. To limit travel to 30 mm, we solve for vv: 0.03=v(0.21)+v240.03 = v(0.21) + \frac{v^2}{4}. This quadratic yields v0.19v \approx 0.19 m/s.

Checkpoint 01

Why is the watchdog polled by the control loop rather than triggered by an event from the command stream?

The watchdog deserves real engineering, because it guards the most common failure in a research stack: a Python process that is alive but silent — wedged on a lock, stalled in a GPU allocation, garbage-collecting at the wrong moment. Two design rules. First, the check runs in the control loop, never in the command callback: a dead publisher cannot invoke anything, so staleness must be polled by the process that owns the arm connection. Second, derive the timeout from a millimeter budget, in two steps. Detection: failure can strike just after a fresh command resets the age counter, and age is observed once per control tick, so detection takes up to TwdT_{wd} plus one control period TctrlT_{ctrl}. Braking: an arm moving at speed vv decelerating at aa travels v2/2av^2/2a. Add them:

dworst  =  v(Twd+Tctrl)detection  +  v22abrakingd_{\text{worst}} \;=\; \underbrace{v\,(T_{\text{wd}} + T_{\text{ctrl}})}_{\text{detection}} \;+\; \underbrace{\frac{v^{2}}{2a}}_{\text{braking}}
Worst-case travel: the stream dies just after a fresh heartbeat, the watchdog notices one check period after the timeout, then the safe-stop ramps velocity to zero.

Plug in typical numbers: v=0.3v = 0.3 m/s, Twd=200T_{wd} = 200 ms, a 100 Hz loop so Tctrl=10T_{ctrl} = 10 ms, deceleration 2 m/s22\ \text{m/s}^2. Detection contributes 0.3×0.21=630.3 \times 0.21 = 63 mm; braking adds 0.09/4=22.50.09 / 4 = 22.5 mm; total roughly 85 mm — wider than the mug you are asking the arm to grasp. Invert it: to keep dworst30d_{worst} \le 30 mm with a 100 ms timeout, the quadratic gives v0.19v \approx 0.19 m/s. The millimeter budget sets both the watchdog timeout and the teleop speed cap. One caveat from the actuator lesson: in position mode a stale-but-fixed goal parks the arm at its last target, usually benign; in velocity mode staleness means runaway. The watchdog matters most for streaming control — exactly what teleop and VLA policies do.

watchdog.py — polled staleness check, owned by the control looppython
import time

class StaleCommandWatchdog:
    """Polled by the 100 Hz control loop; fed by the command receiver."""

    def __init__(self, timeout_s=0.2, on_stale=None):
        self.timeout_s = timeout_s
        self.on_stale = on_stale        # triggers the safe-stop path
        self.last_cmd = time.monotonic()
        self.tripped = False

    def feed(self):
        """Call on every valid incoming command."""
        self.last_cmd = time.monotonic()
        self.tripped = False

    def check(self):
        """Call once per control tick. A dead publisher cannot call
        anything, so staleness must be polled, not event-driven."""
        age = time.monotonic() - self.last_cmd
        if age > self.timeout_s and not self.tripped:
            self.tripped = True
            self.on_stale(age)          # ramp to zero velocity, hold, latch fault
Failure scenarioCatching layerTime to stopWhy the layers above miss it
Teleop or policy process wedges mid-streamStale-command watchdog~350 ms: detection plus braking (≈85 mm at 0.3 m/s)E-stop needs a human; validator and box see nothing — no commands arrive
Policy emits NaN or a target beyond joint rangeCommand validatorOne control tick (~10 ms)Commands are fresh, so the watchdog is silent; firmware faults only after motion starts
Bad IK solution drives fast toward the tableWorkspace box + velocity clampOne control tick (~10 ms)The command is fresh, finite, within joint limits — just aimed somewhere terrible
Gripper stalls while crushing an objectActuator firmware current limitMilliseconds, in firmwareHost-side layers cannot observe motor current fast enough
Human hand enters the workspacePhysical e-stop~1 s including human reactionSoftware cannot defend against situations it was never told about
Host kernel panic or power lossFail-safe default behaviorImmediateNo software is running; the de-energized arm falls under gravity — plan for it
Failure scenarios and the layer that catches each

One rule binds the stack together: a safety path you have never triggered does not exist — the same truth as untested backups. On first bring-up, trip each layer deliberately: kill the teleop process mid-motion and watch the watchdog catch it; command a target outside the box and watch the validator reject it; press the e-stop under load. Each drill should produce a timestamped fault record — the first requirement for the logger below.

Checkpoint 02

Your teleop process wedges — alive but no longer publishing — while streaming velocity-mode commands with the end effector moving at 0.3 m/s. Watchdog timeout 200 ms, control loop 100 Hz, safe-stop deceleration 2 m/s². Which layer catches this, and roughly how far does the arm travel first?

Deterministic reset: throughput is a research instrument

Here is the arithmetic nobody puts in papers. An episode — reset the scene, run the task, stop — is the unit of experiment, and its cost is episode time plus reset time. A teleoperated pick-and-place on a WidowX AI runs 30–45 s; a freehand reset — walk over, reposition by eye, drive home — runs 60–120 s. The reset, not the robot, is the bottleneck:

episodes/hour  =  3600tep+treset,ΔTdataset  =  MΔtreset\text{episodes/hour} \;=\; \frac{3600}{t_{\text{ep}} + t_{\text{reset}}}\,, \qquad \Delta T_{\text{dataset}} \;=\; M \,\Delta t_{\text{reset}}
With a 40 s episode: a 90 s freehand reset yields 27 episodes/hour; a 15 s scripted, fixtured reset yields 65. For an M = 300 episode dataset, shaving 75 s off the reset saves 6.25 hours.

That 6.25 hours is the difference between a dataset collected in one afternoon and one spread across three sessions — during which lighting shifts, the camera mount gets bumped, and your teleop style drifts. Engineering the reset is engineering the dataset. Three tools. Fixtures: freehand placement lands within ±10–15 mm and ±10° of the intended pose; a taped outline tightens that to ±5 mm; a 3D-printed jig gets you to 1–2 mm. Marked poses: define named object start poses in the table frame and record which one each episode used. Reset scripts: one command that drives the arm through a fixed, collision-free joint-space path to qhomeq_{home} and verifies arrival to within 0.01 rad per joint — never hand-drag the arm home: the WidowX AI's gravity-compensated float mode makes backdriving harmless, but a hand-placed home pose is irreproducible.

Reset determinism buys statistical power, not just speed. Comparing two checkpoints at roughly 60% versus 70% success already needs on the order of a hundred trials per arm. If the object start pose meanwhile wanders over a ±15 mm range, task difficulty varies run to run, outcome variance inflates, and a lucky draw of start poses can masquerade as a better policy. Log the start pose and reset type into every episode's metadata so no comparison is ever confounded silently.

Camera mounting: rigidity is calibration

The transforms lesson made Tbase,camT_{\text{base,cam}} — the camera extrinsics — a first-class citizen of your frame tree. The uncomfortable physical fact: that transform is only as constant as the metal holding the camera. Every dataset bakes the extrinsics in implicitly; every pose estimate in Phase 02 multiplies through them explicitly. A mount that flexes changes the physical transform without changing a byte of your calibration file. The sensitivity is a one-line derivation: rotate the camera by a small angle δθ\delta\theta about its center, and a scene point at range dd projects as if the world shifted sideways by dsinδθdδθd \sin\delta\theta \approx d\,\delta\theta; through the pinhole model the image moves fδθf\,\delta\theta pixels for focal length ff in pixels.

εworld    dδθ,Δu    fδθe.g.    δθ=0.5=8.7mrad,  d=0.6m,  f=600px    ε5.2mm,  Δu5.2px\varepsilon_{\text{world}} \;\approx\; d\,\delta\theta\,, \qquad \Delta u \;\approx\; f\,\delta\theta \qquad \text{e.g.}\;\; \delta\theta = 0.5^{\circ} = 8.7\,\text{mrad},\; d = 0.6\,\text{m},\; f = 600\,\text{px} \;\Rightarrow\; \varepsilon \approx 5.2\,\text{mm},\; \Delta u \approx 5.2\,\text{px}
A half-degree mount rotation — invisible to the eye — shifts every scene point about 5 mm in the world model and 5 pixels in the image at RealSense-class geometry (640×480, f ≈ 600 px).

Against your error floor: a decent checkerboard calibration leaves 0.2–0.5 px of reprojection residual, so a half-degree bump is 10–25× the floor — and unlike noise it is coherent; averaging removes none of it. Downstream, object pose estimates shift ~5 mm (half a gripper opening), and a dataset collected half before and half after the bump teaches a policy two conflicting geometries labeled identically.

Mounting practice follows from the math. No plastic goosenecks or friction ball heads on a long lever — they flex by degrees, not milliradians. Bolt the camera to aluminum extrusion or a machined bracket tied to the same rigid structure as the robot base, so a table knock moves both together and Tbase,camT_{\text{base,cam}} survives. Strain-relieve the USB cable — a snag applies more torque than the mount's holding friction — and draw witness marks across every adjustable joint so displacement is visible at a glance. For the wrist-mounted D405, keep the arm-mount screws tight and witness-marked, with a cable service loop that survives full joint excursions; find the failing pose now, not mid-dataset.

The episode logger: Phase 01, serialized

An episode is the atom of everything that follows: a bounded recording of one attempt, reset to stop, sufficient to reconstruct what the robot saw, what it was told, what it actually did, and what went wrong. The logger is where all four lessons converge into one schema: capture-time stamps on a monotonic clock from the timing lesson; a frame-tree snapshot and calibration hash from the transforms lesson; command/acknowledgement semantics from the actuator lesson; an honest relationship with the transport from the ROS 2 lesson.

Why not just record rosbag2 files? Keep recording bags — they are the right tool for debugging transport. But a bag captures topics as transported: arrival-ordered, QoS-shaped, best-effort drops silently absent, no per-tick alignment between camera, state, and command streams. Training pipelines want the opposite: one tick-aligned, schema-versioned record per control step, readable without ROS installed. The episode logger writes that view at collection time, when alignment is cheap, instead of reconstructing it months later, when it is guesswork. Five parts:

  • Episode metadata (once per episode): task name, operator, git SHA of the collection code, schema version, frame-tree snapshot, camera intrinsics and extrinsics with a calibration hash, object start pose and reset type, wall-clock start time for humans.
  • Per-tick observations: capture timestamp (exposure midpoint, host-monotonic) and arrival timestamp for every camera frame, joint positions and velocities, gripper state.
  • Commands: exactly what teleop or the policy requested, with send timestamp.
  • Acknowledgements: what the arm controller actually accepted, echoed back in its ~500 Hz state stream, with receive timestamp.
  • Faults: watchdog trips, validator rejections, e-stop events, dropped frames — timestamped, with a machine-readable cause.
On-disk layout of one episodetext
episodes/2026-07-20/ep_000137/
  meta.json          # schema_version, task, operator, git_sha,
                     # calibration_hash, frame-tree snapshot,
                     # intrinsics/extrinsics, start pose, reset type
  ticks.jsonl        # one record per control tick, append-only
  frames/
    wrist_000000.jpg # filename encodes camera + tick index
    wrist_000001.jpg
    side_000000.jpg
    ...
  faults.jsonl       # one record per fault event
  end.json           # written only on clean shutdown: status, tick count
episode_logger.py — the API sketchpython
import json
import time
from pathlib import Path

class EpisodeLogger:
    SCHEMA_VERSION = 3

    def __init__(self, root, meta):
        self.dir = Path(root) / time.strftime("ep_%Y%m%d_%H%M%S")
        (self.dir / "frames").mkdir(parents=True)
        meta = dict(meta)
        meta["schema_version"] = self.SCHEMA_VERSION
        meta["t0_monotonic"] = time.monotonic()
        meta["t0_wall"] = time.time()   # human-facing only, never for math
        (self.dir / "meta.json").write_text(json.dumps(meta, indent=2))
        self.ticks = open(self.dir / "ticks.jsonl", "w")
        self.faults = open(self.dir / "faults.jsonl", "w")
        self.n = 0

    def log_tick(self, obs, cmd, ack):
        rec = {
            "tick": self.n,
            "t_capture": obs["t_capture"],   # exposure midpoint, monotonic
            "t_arrival": obs["t_arrival"],   # host receive time
            "q": obs["q"],                   # joint positions, rad, 6 floats
            "dq": obs["dq"],                 # joint velocities, rad/s
            "gripper": obs["gripper"],
            "cmd_q": cmd["q"],               # what the policy asked for
            "t_cmd": cmd["t_sent"],
            "ack_q": ack["q"],               # what the controller accepted
            "t_ack": ack["t_recv"],
        }
        self.ticks.write(json.dumps(rec) + "\n")
        self.n += 1

    def log_fault(self, kind, detail):
        rec = {"t": time.monotonic(), "kind": kind, "detail": detail}
        self.faults.write(json.dumps(rec) + "\n")
        self.faults.flush()                  # faults are never buffered

    def save_frame(self, cam, tick, jpeg_bytes):
        name = cam + "_" + str(tick).zfill(6) + ".jpg"
        (self.dir / "frames" / name).write_bytes(jpeg_bytes)

    def close(self, status):
        self.ticks.close()
        self.faults.close()
        done = {"status": status, "ticks": self.n}
        (self.dir / "end.json").write_text(json.dumps(done, indent=2))

Three design decisions carry the weight. Crash safety by construction: ticks and faults are append-only JSONL, and end.json is written only on clean shutdown — its absence marks a crashed episode that remains analyzable to the last line, exactly like a write-ahead log. The control loop never blocks on I/O: JPEG-encoding a 640×480 frame costs 5–15 ms, which would blow a 10 ms control tick; in production save_frame hands bytes to a separate writer process over a queue — the sketch shows the interface, not the concurrency. Schema versioning from record one: you will change this schema, and episode 40 from three weeks ago must stay loadable when you do. JSONL is deliberately boring; swap in Parquet once the schema stabilizes — the fields matter more than the format.

Checkpoint 03

Analyzing your first recordings, you find that commanded joint targets (cmd_q) and acknowledged values (ack_q) disagree by up to 0.03 rad on about 4% of ticks. What is the best interpretation?

The exit gate: record, replay, reproduce

Now prove the station works end to end with a two-minute teleop recording. At 20 Hz that is 2,400 ticks; two RealSense-class cameras at 30 fps add 7,200 frames, roughly half a gigabyte of JPEG — a useful first calibration of storage burn (a serious collection day runs 50–100 GB). Make the recording exercise the whole station, not just the happy path:

  • Start from a scripted reset, with the start pose recorded in metadata.
  • Sweep the arm through most of the reachable workspace, at varying speeds.
  • Include at least three gripper open/close events and one full pick-and-place.
  • Trip the watchdog once on purpose (pause the teleop publisher mid-motion) so the episode contains a real fault record and a safe-stop.

Validation happens at two levels. Level one, data replay: load the episode on a machine with no ROS and no robot, render a video with joint traces and command-versus-ack overlays, and check invariants mechanically — capture timestamps strictly increasing, tick-period jitter within budget (p99 under twice nominal), every command paired with an ack or a fault, frame count matching tick count, metadata complete. Level two, open-loop re-execution: from the same scripted reset, feed the logged commands back at the logged cadence and measure how closely the arm retraces — with a deterministic reset, per-joint RMS deviation should land under 0.01 rad, and on an arm specced at 1 mm repeatability most of any larger residual is your reset procedure, not the arm. That residual is your station's repeatability noise floor; write it down, because every claim in the Phase 05 latency experiments must clear it to count as signal.

Then the gate itself, quoted from this module's contract: a second engineer can reproduce the setup and recover a synchronized episode using only your repository. No shoulder-surfing, no "oh, you also need to…" over chat — every such moment is a missing README line; fold it in and rerun. If no second engineer is handy, the honest substitutes are a clean account on a different machine, or yourself in two weeks, which is operationally the same person. This is the standard you already hold infrastructure to: if it only works on the machine where it was born, it does not work.

Studio exercise 01

Run the Phase 01 exit gate

(1) Record the two-minute teleop episode described above, including the deliberate watchdog trip. (2) Write validate_episode.py, enforcing at least six invariants against an episode directory: strictly increasing capture timestamps, tick-period p99 below twice nominal, every command paired with an acknowledgement or a fault record, frame count consistent with tick count, all joints within configured limits, complete metadata (git SHA, calibration hash, schema version, start pose). (3) Run the second-engineer test: a clean clone, another person or a clean machine, and only the repository between them and a synchronized replay with plots.

Need a hint?

Write the validator as pytest cases taking the episode directory as a fixture. Then test the tester: copy the episode, corrupt the copy — delete one frame, swap two tick records, blank the calibration hash — and confirm the validator fails on each. A validator that has never failed is exactly as trustworthy as a watchdog that has never tripped.

Where this goes next: the previous lesson, ROS 2 essentials: nodes, topics, QoS, and bags, gave you the transport this station runs on; this lab froze everything above it into a safe, reproducible instrument. Phase 02 opens with Forward kinematics from scratch — no coincidence that the workspace box you built today already needed FK to check a Cartesian bound. From here on, every model you derive and every policy you train runs on this station, and every claim rests on the episodes this logger records.