Skip to content

Sensors and devices

This is what makes a botrail environment behave rather than sit there: sensors that read the world into signals, and devices that move parts of it — belts, axes, magazines and vehicles. Both live on the same scan clock as the sequencer, and both show up as waveform lanes on the baked timeline.

Zone sensors

A box-shaped presence sensor. Its name becomes a read-only input signal, ON while a watched body overlaps the zone:

scene.add_zone_sensor("station_busy",
                      position=(0.0, 0.6, 0.8), size=(0.5, 0.5, 0.6),
                      watch_robots=["near"])

What it watches is explicit:

  • watch=[...] — a list of obstacle names; default is every obstacle.
  • watch_robot=True / watch_robots=[...] — sense robot links too.
  • watch=[] with watch_robot=True — a robot-only light curtain.

A zone says "somebody is inside", not who — so an interlock between two arms needs one zone per arm over the same volume; a single zone watching both would be tripped by the very arm waiting on it.

Beam sensors

A photoelectric beam between two world points, ON while interrupted:

scene.add_beam_sensor("eye", frm=(0.0, 0.4, 0.3), to=(0.0, 0.8, 0.3),
                      radius=0.005, watch=["crate"])

Two things bite in practice. The beam trips when a part's leading face reaches it, so a station sensor belongs half a part-width downstream of the taught pose. And a beam over a running belt is a momentary signal — the part crosses and the lane drops again. A transition that isn't currently waiting on it will miss it; latch it into an internal signal, or gate the step so it is already waiting when the part arrives.

Conveyors

scene.add_conveyor("belt",
                   zone_position=(-0.2, 0.6, 0.3), zone_size=(1.2, 0.3, 0.3),
                   velocity=(0.25, 0.0, 0.0), running=False)

A conveyor is a transport zone, not a mesh: while running, any unattached obstacle whose origin lies inside the zone is carried at velocity. Put the zone above the belt's slab so it carries the goods and not the structure. Drive it from steps with bt.seq.start/stop/set_speed; its running state is a signal lane (tl.signal("belt")), which is how "the belt ran exactly through feed" becomes an assertion.

Collision-disabled obstacles still ride — that is the trick behind moving scenery like belt cleats.

For an indexing line, command the pitch as a distance instead of driving start/stop by timer: bt.seq.advance("belt", 5.2) runs a stopped belt for exactly 5.2 m and stops, and bt.seq.device_done("belt") is the await. The final scan tick moves exactly the remainder, so the pitch is exact no matter how the scan period divides it — see Indexed transfer.

Sources and sinks: endless supply, finite pool

A baked timeline holds a fixed set of named object tracks, so "endless supply" is authored as a finite pool plus a return loop, which is also what a real accumulation line is:

scene.add_source("cartons", pool=[f"box_{i}" for i in range(6)],
                 park=(-1.75, 0.62, -0.45),          # the magazine
                 pitch=(0.0, 0.0, -0.07),            # member i parks at park + pitch*i
                 position=(-2.25, 0.62, 0.66),       # where fed members appear
                 interval=0.0, running=False)
scene.add_sink("line_end", zone_position=(1.3, 0.62, 0.66),
               zone_size=(0.12, 0.4, 0.05), source="cartons")

interval=0.0 makes an indexing feeder — one member per bt.seq.start — which is how you guarantee pool order is arrival order. A periodic feeder (interval=2.0) feeds on the clock instead. Members reaching the sink go back to the source's magazine, free to be fed again. A member that does not start on its park slot starts out on the line — an already-loaded belt.

Linear axes

A door, a lifter, an indexing table — one axis, position-commanded:

scene.add_linear_axis("door", objects=["door_panel"],
                      axis=(0.0, 0.0, 1.0), speed=0.4,
                      range=(0.0, 0.6), position=0.0)
sq.step("open_door", actions=[bt.seq.move_to("door", 0.6)],
        transition=bt.seq.device_done("door"))

The axis moves its listed obstacles along axis at speed, clamped to range; device_done is the in-position condition.

Name its positions and the axis reads like the door it is:

scene.add_linear_axis("door", objects=["door_panel"], axis=(0, 0, 1), speed=0.4,
                      range=(0.0, 0.6), stops={"closed": 0.0, "open": 0.6})
sq.step("open_door", actions=[bt.seq.move_to("door", "open")],
        transition=bt.seq.device_done("door"))
sq.step("start", transition=bt.seq.signal("door/closed"))   # the interlock

Every stop is a read-only input lane <axis>/<stop> — ON while the axis stands at it, off between stops (MTConnect's unlatched) — the limit switch an interlock waits on, and a DI on the I/O list.

What the axis drives is checked against every robot each tick, the way two arms are checked against each other: a leaf closing on an arm still inside is a DeviceCollision error at that instant, naming the device, the obstacle, the robot and the link. The cure is the interlock — the robot reports itself clear before the door is commanded — not a different door. A lift's car is checked the same way against every robot that is not riding it.

Vehicles

The fifth device is a guided transport vehicle: it drives an authored path station to station, carries its body and whatever is on its deck, and is commanded with goto / awaited with device_done — the same pair a linear axis uses.

scene.add_vehicle("agv", body=["/World/AGV"],
                  path=[(-2.6, -2.9), (0.0, -2.9)],
                  stations={"warehouse": 0, "dock": 1},
                  speed=0.8, start="warehouse")

A robot can ride one, which makes it an AMR. Vehicles have enough of their own rules — the aisle check, trays, mounted sensors, what happens to planned motions while driving — to get their own page: Vehicles and AMRs.

Lifts

The sixth device is an elevator: a car of ordinary obstacles moved along an axis between named stops, carrying whatever its capture zone holds when the ride is commanded — loose parts by origin, and vehicles whole, the chassis, the deck load and any mounted robot riding one rigid motion.

scene.add_lift("lift", car=["lift"],                # obstacles, prefix ok
               zone_position=(3.25, 0.0, 1.0), zone_size=(1.3, 1.3, 2.0),
               stops={"1F": 0.0, "2F": 2.2}, speed=0.6)

Command it with bt.seq.move_to("lift", "2F") and await device_done. The cargo is fixed the moment the command fires — an elevator moves after the doors close — so a vehicle still driving refuses the ride, a vehicle half out of the zone refuses to board by name, and nothing joins mid-ride.

The vertical hop in a vehicle's path (two waypoints stacked at the car) is a lift edge: validation accepts it only where a lift's zone covers both ends at its stops, and goto never walks across it — drive to the near side, ride, continue. A stop between floors leaves the vehicle off its path, and the next goto says so.

Doors are not part of the device, and need no special vocabulary: a panel on a add_linear_axis physically blocks the path while closed — boarding through it simply fails the aisle check — and the open/close steps are ordinary sequence lanes on the timing chart. examples/vehicles/lift_demo.py runs the whole interlock chain: call → door open → board → door close → ride → alight.

Cameras

A named viewpoint with pinhole optics, drawn in the studio as a body plus a wireframe frustum whose aspect follows resolution and whose angle follows fov (horizontal, degrees). A camera is presentation only: it publishes no signal and never affects planning or the cycle — it answers "what does this camera see from here", the layout question you get asked before commissioning.

# A fixture, aimed at a world point (-Z looks, +Y is image-up):
scene.add_camera("overview", position=(1.6, -1.4, 1.3), look_at=(0, 0, 0.3),
                 fov=60, resolution=(1280, 720))
# A wrist camera, offset in the link frame:
scene.add_camera("eye_in_hand", robot="ur", link="tool0",
                 position=(0, 0.05, 0.03), fov=50, resolution=(1920, 1080))
# Riding a vehicle deck:
scene.add_camera("agv_front", mount="agv", position=(0.3, 0, 0.4))

Deselected, the frustum draws as a compact aim gizmo; selecting the camera (scene tree or click) extends it to the far clip for coverage checks, and a world-mounted camera gets the move/rotate gizmo. Mounted cameras ride their machine during playback like mounted sensors do.

A real camera comes straight from the catalog:

scene.add_camera("inspect", from_catalog="realsense/d400/d435",
                 robot="ur", link="tool0", position=(0, 0.06, 0.02))

The package's flat specs become the optics (fov, resolution, and the near/far band from its rated range — explicit arguments still win), the given pose places its mount face while the optical axis follows the package's own calibration (frames.camera_frames), and the identity lands on the BOM as a sensor.camera line, pinned to the catalog revision. From there the selection loop closes like for any other equipment: scene.requirements() derives what the cell asks of the camera — the authored framing always, a working-distance band when a vision sensor judges through it — and scene.check() answers ok / spec_short / spec_unknown against the part's stated specs. (The camera is the purchasable article; vision sensors add requirements to its line, never a line of their own.)

Selecting a camera also opens its picture-in-picture at the viewport's top-right: the live view through that camera — scenery, robots and process light, with the authoring aids (grid, gizmos, sensor volumes, overlays) hidden. The header switches between cameras and toggles the size; closing the panel stops the second render pass entirely. It coexists with the SFC/ladder/I/O overlays, and the picture follows playback, so a wrist camera shows the approach as the arm moves.

Vision sensors

A camera becomes an input by putting a vision sensor behind it: the sensor's name becomes a read-only signal, ON while a watched body overlaps the camera's view frustum.

scene.add_vision_sensor("part_seen", camera="eye_in_hand", watch=["workpiece"],
                        detect_range=(0.3, 2.0))   # default: the camera's near/far

The camera is the optics — pose, mount, field of view all come from it, so a wrist camera's sensor sweeps with the arm — and the sensor is the judgement. It is a geometric judgement: frustum overlap plus (by default) a single occlusion ray from the camera to the body's origin, so a part hidden behind a wall does not trip it. No pixels are rendered or interpreted — it answers "was it in view", not "would the vision system have detected it". Robot links, when watched, trip on overlap alone and never occlude. Like every sensor, the lane shows in the timing chart, the SFC/ladder views, and derives an input contact in the I/O map; the BOM line stays on the camera (sensor.camera) — the sensor is logic, not hardware.

With a bake (or a motion preview) on the timeline dock, ⤓ cam records the PiP camera's view as a WebM video, right in the browser: the baked tracks are re-walked on a fixed 30 fps grid — not captured in real time — so the export never drops a frame and the same bake always produces the same file. Needs WebCodecs (Chrome, Edge, or a recent Firefox); the button says so when it can't run. The same export runs headless from Python and CI — botrail.capture.record_camera, or botrail capture on the command line.

Housekeeping

scene.sensor_names;  scene.remove_sensor("eye")
scene.device_names;  scene.remove_device("belt")
scene.camera_names;  scene.remove_camera("overview")

Sensors and devices are saved in projects, appear in the studio's scene tree, and their lanes are queryable on every bake — Timeline assertions shows how to test against them.