Parts and the bill of materials¶
A cell is more than geometry and behavior: every conveyor, sensor, controller and fence panel in it is a product somebody buys. botrail keeps that identity on the residents themselves — a part is what a thing is commercially — and derives the bill of materials from the scene, the way the I/O list is derived from the sequences. You author what is in the cell and where; the parts list falls out, and it can never disagree with the scene it was read from.
scene.set_part("belt", manufacturer="MISUMI", model="GVL-1200", mass_kg=45)
scene.set_part("eye", catalog="keyence/pz-g61n", model="PZ-G61N")
scene.set_part("fence", category="structure.fence", model="FP-2000", qty=12)
bom = scene.bom()
print(bom.to_markdown())
scene.export_bom("bom.csv")
What a part is¶
Scene.set_part pins identity to a resident by
name — a robot, a device, a sensor, an I/O node, an obstacle, or an obstacle
group (everything under name/: an imported USD subtree, a generated
assembly). Every field is optional:
| field | meaning |
|---|---|
catalog |
a catalog reference — "id", "id@revision", or (id, revision) |
manufacturer, model |
maker and model / part number |
category |
the BOM category (conveyor, sensor.photoelectric, structure.fence, ...); overrides the one the resident's kind implies |
description |
free text |
qty |
how many the target stands for — one by default; a fence group generated as twelve panels says 12 |
any other keyword, or attributes={...} |
free attributes: numbers (mass_kg=45, power_w=200, a price) are summed by bom().total(key), text (finish="RAL 7035") is carried along |
A part carries no geometry and no behavior. The shape stays on the obstacle, the motion on the device, the wiring on the I/O map; the part is only the name and the count. That is deliberate — botrail does not model shapes, and it does not size anything: it counts and checks.
Names live in separate name spaces (a conveyor device and its belt slab may
both be belt), so when a name resolves to several things set_part asks for
kind= ("robot", "device", "sensor", "io_node", "obstacle",
"group"). Re-pinning a target replaces its part; removing the resident
removes the pin.
Generated structures come identified¶
The standard parts — bt.parts.fence, table,
pedestal, conveyor, pallet, light_curtain, photoelectric — pin their parts as they
build: a fence is one line for the panels (its quantity the panel count),
one for the posts, one for the door; a conveyor's identity sits on the
device, its body being geometry. Give them model= / manufacturer= /
attributes and the BOM line is complete the moment the thing exists.
Catalog robots need no authoring¶
A robot loaded with Robot.from_catalog already
knows what it is: the package's manifest names the maker, the product, the
category and the headline specs, and botrail keeps them on the model's
provenance record — so a catalog arm is an identified BOM line with its
id@revision, payload_kg, reach_mm and mass_kg the moment it enters
the scene, and a tool welded on with attach_tool is its own line. A part
pinned to the robot's instance name overlays that (a description, a price,
a different category); it does not replace it.
Tools in the stack¶
A tool welded onto the robot with attach_tool is a line of its own,
named by its place in the stack — arm/tool for the first, arm/tool2
for the next, arm/tool/tool3 for one riding a tool that is itself a
stack. A catalog tool brings its identity; one made from a URDF string
(a bt.tools.multi_tool bracket, say) does not, and gets it pinned by
that row name:
scene.set_part("arm/tool", kind="tool", category="tool.multi",
model="Custom bracket", description="In-house design; mounting fit unverified")
An authored reference such as the former MPH-3 is not an orderable commercial product. Leave its catalog identity and mass unset unless they are established; the assembly and machine-tending demos use identified commercial tools.
The pin follows the robot through a rename and rides the project; on a catalog tool's row it is the last word.
The bill of materials¶
Scene.bom lists every piece of equipment the scene
holds — robots and their tools, conveyors, axes and vehicles, sensors,
controller boxes — whether or not it has been identified, plus every obstacle
or group a part was pinned to. Bare obstacles are geometry (scenery, stock, a
workpiece pool) and are not parts unless you say so; a Source/Sink pair
models an endless line and is not equipment either.
Identical products merge into one row with the quantity summed and the
resident names kept for traceability; a row that names nothing yet — no
catalog reference, maker or model — stays under its own resident name, so
bom.unidentified() is the purchasing to-do list.
| # | category | manufacturer | model | catalog | qty | names | mass_kg |
|---|----------------------|--------------|-------------|----------------------|-----|------------------|---------|
| 1 | manipulator | FANUC | M-20iD/25 | fanuc/m/m-20id-25@… | 1 | arm | 250 |
| 2 | robot_controller | FANUC | R-30iB Plus | | 1 | arm/controller | |
| 3 | conveyor | MISUMI | GVL-1200 | | 1 | belt | 45 |
| 4 | sensor.photoelectric | KEYENCE | PZ-G61N | keyence/pz-g61n | 1 | eye | |
| 5 | plc | | R04CPU | | 1 | PLC1 | |
| 6 | part | | HFS8-1200 | | 2 | table_a; table_b | 30 |
| 7 | structure.fence | | FP-2000 | | 12 | fence | 8 |
Totals: mass_kg = 451
Every arm needs a controller box, so the bill lists one per arm before
anybody declares it: the line <robot>/controller (category
robot_controller). A catalog arm names it where its maker lists exactly
one controller (the manifest's specs.controller); an arm sold as a set
with its controller — a UR e-Series, a Doosan M — carries the set's
identity, so the line is no second purchase; otherwise it stays unidentified
with what is known in its description (the candidate controllers, the
set's note), which is what the to-do list should say. Pin identity on it
like any other line (scene.set_part("arm/controller", manufacturer="FANUC",
model="R-30iB Mate Plus")), or declare the cabinet in the
I/O map (scene.add_io_node("UR", kind="robot_controller",
robots=["arm"])): the node's own line then stands for it, and a pin on the
derived name follows the arm onto it. bt.parts.controller
does the whole thing at once — the box on the floor plan, the node and the
line are one name. The cables and tubes a cell routes are
derived lines too — one per route under cable.<medium> or
pneumatic.tube, with its cut and purchase length, routes of one spec
merged with their lengths added (as are ducts of one model, and anything
else bought by the metre under cable.*), and a line per clamp article. A vehicle carries its controller on board, and a gripper
or a hand loaded on its own is driven by someone else's cabinet — neither
gets a line.
The Bom renders as CSV, Markdown or JSON (to_csv(),
to_markdown(), to_json(), save(path); scene.export_bom(path) picks the
format from the extension), and its numbers are pytest material like
everything else botrail bakes:
bom = build_cell().bom()
assert bom.unidentified() == [] # everything has a number
assert bom.total("mass_kg") <= 1500 # None, not 0, when nobody says
assert bom.total("power_w") <= 20_000
The generated Python and the .botrail project carry the pins, so a
reloaded cell — or the script generate_python() writes — produces the same
table. The studio shows the same
table over the viewport, wears each line on its row of the scene tree,
and answers "what is this" for whatever is clicked.
What it is not¶
The BOM is a derived table, not a purchasing system: botrail holds the
slot for a price and sums it if you fill it in, but it does not know prices,
lead times or stock, and it does not validate attributes. Nothing here sizes
a frame, a supply or a valve — a part's mass_kg is a number you wrote down.
What the cell asks of each line¶
A BOM line also carries requirements — the payload, reach, stroke, span,
load the cell implies for it — and the check compares them with the part's
attributes. See Selecting parts: scene.requirements(),
scene.check() and bt.catalog.search.