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.
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:
- read
scene.requirements().to_json()— what each line needs and why; - for a line that is
unidentifiedorshort, callbt.catalog.search_for(row)and pick from what comes back — never from memory; - write the pick with
Product.identify(scene, target); scene.check()until no errors remain; a line with no candidates stays anunidentified_partfinding — 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.