Skip to content

Selecting parts: requirements, the check, and the catalog

botrail does not choose parts. What it does is derive what every line of the bill of materials must be able to do from the cell it sits in, compare that with what the chosen part says it can do, and report where the two disagree — or where nobody has said. Choosing stays with you, your agent, or the vendor you send the open questions to. bt.catalog.search gives that choice a list of products that exist.

req = scene.requirements()        # one row per BOM line
print(req.to_markdown())

report = scene.check()            # I/O lint + sequences + parts + requirements
assert report.ok, report.to_markdown()

bt.catalog.search("gripper.parallel", stroke_mm=150, payload_kg=2.3)

What the cell asks of a line

scene.requirements() walks the BOM and derives, for each line, the numbers the cell already implies. Every requirement is a minimum and says where it came from:

line kind requirement derived from
robot payload_kg the tool's mass plus the heaviest hold: a part the robot grasps (now, or in an attach of a counted sequence), or the parts one step attaches together — a group part's mass counted once, not once per piece
robot reach_mm the farthest taught segment goal from the base, measured at the flange a catalog robot declares (the TCP for a plain URDF), plus margin (10 % by default); per arm on a dual-arm robot, from the arm's own first joint (its shoulder — group.first_link, not the origin of the body link the shoulder is bolted to) as each target was taught — a torso moves it between targets — not asked of a robot mounted as its vehicle's wheels (next row but one): it carries its arms' bases itself, so that distance is the machine's, not the cell's — (an arm mounted from the catalog is its own line, <robot>/<arm>)
robot (dual-arm product) arm_count the arms the cell's motions use — a motion of a torso-with-arm composite teaches the arm inside it
robot, mounted as its vehicle's wheels (mount_robot(wheels=)) vertical_reach_min_mm (≤), vertical_reach_max_mm (≥) the lowest and the highest taught hand position over the floor the machine stands on — what tells a lift column from a bowing waist; the line is shopped for as vehicle.mobile_manipulator, and asks no reach_mm (a note on the line says why: whether a machine reaches is its teaching's and its bake's to say)
robot, a forklift (mounted as its vehicle's wheels, lifting with a mast group and no arm) lift_height_mm (≥), fork_height_lowered_mm (≤), max_speed_fork_first_mps the highest and the lowest position the cycle's ramps and motions put the fork seat (the package's TCP) at, over the floor the truck stands on — the put-away's height, a floor pallet's pockets; the forks-first travel speed (the vehicle's forward gear: a forklift's forks are its +X), beside the max_speed_mps of the faster gear. The line is shopped for as vehicle.forklift and asks no reach_mm; a pallet's boards and the load on them, attached in one step, are one hold for payload_kg
tool (<robot>/tool) payload_kg the heaviest grasped part
tool, gripper.parallel stroke_mm the smallest side of the grasped parts — the least the fingers must open
beam sensor sensing_range_mm the beam's span
light curtain (sensor.light_curtain) range_mm, protective_height_mm the beam's span, the post height
zone sensor range_mm the half-diagonal of the zone
conveyor length_mm, width_mm, speed_mps, load_kg the transport zone along and across the belt, the belt speed, the mass of the parts starting on it
linear axis stroke_mm, speed_mps, load_kg the range, the speed, the mass of what it carries
vehicle max_speed_mps, payload_kg the travel speed (the faster gear, where a reverse_speed is stated); the mass of the parts starting on its deck (tray), counted at the parked frame
vehicle, aerial max_climb_mps, max_descent_mps the authored climb and descent rates
vehicle, aerial flight_time_min the airborne time of the baked cycle — pass requirements(timeline=tl); without it the comparison is left as a note
I/O node di, do, ai, ao, safe_di, safe_do the points assigned to it
robot controller, placed (bt.parts.controller, or place= on the node) cable_m the robot cable: the cut length of the route that runs from the arm to the box when one is declared; otherwise from the arm's base to the box along the axes (a cable runs in a duct, not as the crow flies) plus cable_slack_m (1 m by default); the farthest arm of a shared cabinet
pedestal / table load_kg the robots standing on it (base inside its top, at its height) and their tools
cable carrier — the dress pack (bt.routing.carrier) inner_mm; with a timeline free_length_m, stroke_m, torsion_deg_m, bend_r_moving_mm (≤) the bundle's Σ od² at 50 % fill; over the baked cycle the holders' farthest distance plus a bend radius per span, their stroke (what a retraction takes up), the twist per metre of hose, and the tightest bend the replay showed — derived for the table and for search_for, not linted twice: the route checks (duct_fill, route_tension, route_twist, route_bend_moving) already say it of the lines inside
power supply (power_supply) output_a the sum of current_a over the parts at its voltage — a part's voltage_v against the supply's output_v; a part that does not say its voltage counts against every supply, with a note

A number the cell cannot supply is not guessed: a grasped part with no mass_kg leaves the payload out and adds a note (requirement_incomplete) saying which part to give a mass. Requirements are geometric and deterministic; there is no sizing, no safety evaluation, no choosing.

The per-voltage sum is the whole story until a cell has to say which supply feeds which load — two 24 V units, a drive on its own 48 V supply. That is the connection plan: ports declared on the equipment, connections between them, and a report checked supply by supply.

A machine that is the robot — legs (vehicle.legged), or a whole airframe on an aerial vehicle (vehicle.uav) — has one BOM line, and its vehicle's requirements land on it, where the package's specs answer them. An aerial line nobody has identified is narrowed into the vehicle.uav category so search_for shops the one aisle that flies; ground vehicles keep the generic vehicle — cart, AGV or AMR stays your call.

Flight time is a cycle fact, so it wants the baked cycle:

tl = scene.simulate_sequences(["survey", "tend"])
req = scene.requirements(timeline=tl)     # adds flight_time_min
report = scene.check(timeline=tl)         # ... and compares it

The airborne seconds are read exactly off the vehicle's closed-form track (tl.vehicle_airborne("drone")): every moving span plus every hover above the starting pad — waiting on the pad costs nothing. This is a comparison against the manufacturer's declared hover endurance (flight_time_min, quoted with its payload and battery conditions on the catalog manifest), not a battery model: charge level, degradation and the shorter endurance under payload are not simulated. The reference altitude is where the machine starts parked.

What the part says

The "provided" column comes from the line's attributes — the specs a catalog package carries (Robot.from_catalog, attach_tool, bt.parts.*(catalog=...), bt.catalog.Product.identify) or the numbers you type on set_part:

scene.set_part("belt", manufacturer="MISUMI", model="GVL-900-200",
               length_mm=900, width_mm=200, max_speed_mps=0.2, load_kg=10)

A requirement key is answered by the attribute of the same name, or one of its aliases (speed_mps reads max_speed_mps / speed_max_mps; load_kg reads capacity_kg / payload_kg; see bt.select.ALIASES). The vocabulary is shared with the catalog's manifests, so a value arrives in the same column whichever way it came.

Each requirement ends in one of four states:

status meaning finding
ok the part states a value that satisfies the requirement —
short the part states a value that does not spec_short (error)
unknown the line is identified (maker / model / catalog) but states no value spec_unknown (warning)
unidentified nobody has said what the part is carried into the unidentified_part note — the question to ask a vendor

An I/O node's capacity is the exception: that is the I/O report's business (unbound, capacity), so those requirements appear in the table but raise no spec finding — a controller's cable_m does.

The cabinets' placement is checked beside the requirements. controller_unplaced (info) names an arm's controller nobody has put on the floor plan — the derived <robot>/controller line, or a node declared without place. service_space (warning) names what stands in the service space in front of a control cabinet's or a controller's door (the frame <name>/front the generators leave): the door's width by the box's height, service_clearance_m deep — 0.9 m by default, the working space electrical codes ask for — or the maker's own figure when the part states service_clearance_mm (a controller ordered from a catalog pack does). Both knobs are arguments of scene.check(); cable_slack_m is what the cable requirement adds to the run from arm to box.

The check

scene.check() is every static check of the cell in one list — what botrail check prints: the I/O lint, each sequence walked for dangling references, unidentified lines with what the cell asks of them, and the requirement comparison. A tool whose installation documents require a part that is not on its attachment path — a bare 2F-85 without its coupling — is a warning too (required_part_missing, from the mounting report). ok is false only on errors; unknowns and notes are information, not failure.

report = scene.check()
for f in report.findings:
    print(f.severity, f.code, f.message)
error   spec_short          ur5e/tool: stroke_mm 85 < required 150 (smallest side of carton (150 mm))
warning spec_unknown        belt: needs load_kg >= 2.3 (1 part(s) on the belt at start) but the part does not say — add load_kg= on set_part or pick a catalog item
info    unidentified_part   eye (sensor.photoelectric) has no maker, model or catalog reference — needs sensing_range_mm >= 500
info    requirement_incomplete  ur5e: payload counts no mass for lid — give them mass_kg on set_part

A spec_short is the cell catching a wrong pick (a 2F-85 cannot open past a 150 mm carton). An unidentified_part with its needs ... is the line of a request for quotation. Both are recomputed every time the cell changes: make the carton heavier and the robot's payload requirement moves with it.

Finding candidates

bt.catalog.search reads the catalog's published index — category, maker, numeric specs, validation level — and filters it the way a requirement reads:

bt.catalog.search("gripper.parallel", stroke_mm=150, payload_kg=2.3)   # minimums
bt.catalog.search("manipulator", reach_mm=900, payload_kg=6, level="V3")
bt.catalog.search("gripper", mass_kg__max=1.0, ip_rating="IP54")        # a maximum, a string spec
bt.catalog.search_for(scene.requirements()["eye"])                      # straight from a requirement row

A product that states a value that falls short is out. One that states nothing for an asked spec is a different thing, the same difference the cell's check makes between short (an error) and unknown (a warning): it is a candidate nobody has confirmed. It comes back after the confirmed ones, with the silent keys in unstated —

for p in bt.catalog.search_for(req["machine"]):
    print(p.id, p.unstated)
# unitree/g1/g1-d/r1 ()
# ...
# rainbow_robotics/rb-y1/rb-y1-a/r2 ('vertical_reach_max_mm', 'vertical_reach_min_mm')   <- the vendor publishes no working heights

— and once identified, that line reads unknown until someone supplies the number or the cell is baked with the machine in it. This applies to specs the product's category is asked at all, which the index says by itself: a spec some product of that category states. Nobody states a vacuum gripper's stroke, so a vacuum gripper is not a candidate for one. strict=True leaves the unconfirmed out, and search_for(row, key=None) drops a requirement from the search without changing what the row asks; a <= requirement (a fork height, a lowest working height) travels as the key__max filter.

Results come ordered by how much is unstated (nothing first), then by validation level, then by closeness to the minimums (the snuggest fit first), then by id, so the same query always returns the same list. An empty list is information too: nothing in the catalog satisfies the cell as drawn, so change the cell (grasp the carton across its other side) or send the requirement out as a question.

Writing the pick back is one call. The identity and the numbers come along, so the next requirements() reads them:

cands = bt.catalog.search_for(req["eye"])
cands[0].identify(scene, "eye")        # set_part(catalog=(id, revision), maker, model, specs...)
assert scene.requirements()["eye"].status == "ok"

The index is fetched with huggingface_hub (pip install botrail[catalog]) and pinned to a dataset commit; when the hub cannot be reached the newest copy already in the Hugging Face cache is used, and bt.catalog.index(path=...) or BOTRAIL_CATALOG_INDEX reads a local file (a catalog builder's dist/index.json). set_part(catalog=...) with an id that is not in the catalog is still allowed — it just stays unknown until the part's numbers are typed, which is the point: a made-up model number cannot pass for a verified one.

Working with an agent

The loop an agent runs is the loop a person runs, with the guesswork removed:

  1. read scene.requirements().to_json() — what each line needs and why;
  2. for a line that is unidentified or short, call bt.catalog.search_for(row) and pick from what comes back — never from memory;
  3. write the pick with Product.identify(scene, target);
  4. scene.check() until no errors remain; a line with no candidates stays an unidentified_part finding — the question to hand to a human or a vendor.

See Working with agents and automation for the rest of the protocol, and Parts and the BOM for where the identities live.