Skip to content

Bolt a cover down

Walks through examples/assembly/cover_bolting_demo.py — a bolted joint as data, the screwdriver's program beside the arm's, and the hand-over set with the tightening sheet, the interlock table and the FAT rows. Fetches the UR5e, the OnRobot Dual Quick Changer v3, the side-mounted Screwdriver 103961, the RG6, the robot stand and the control box from the catalog (cached after the first run).

A cobot at a bench takes a gearbox cover by its bearing boss, sets it on the housing's two dowels, and screws it down: six M5 screws from a presenter, in the star order the joint derives, each run down by the screwdriver on the same wrist as the gripper. Nothing in it is a thread simulation — the joint is a drawing's figures, the rundown is the tool's own stroke timed from the thread's pitch, and the driver's answer is a signal — which is exactly what the reviewer of a screwdriving cell wants to read: the numbers, the order, the guards, and what the cell does when a screw does not go in.

python examples/assembly/cover_bolting_demo.py cover_bolting.usdc
cover bolting: cycle 89.11s, 6 screws in the order ['h5', 'h4', 'h0', 'h2', 'h1', 'h3']
...
fastening report:
  #1 h5 screw0: off 0.01 mm, tilt 0.00°, down 20.0 mm in 4.46s, ok (1) — align=pass tilt=pass seated=pass engagement=pass depth=pass torque=pass
  #2 h4 screw1: off 0.01 mm, tilt 0.00°, down 20.0 mm in 4.46s, ok (1) — align=pass tilt=pass seated=pass engagement=pass depth=pass torque=pass
  ...
cover fit: off 0.00 mm, -0.00 mm high, tilt 0.00° — position=pass tilt=pass seated=pass
per screw: 11.7s (driver 4.65s of it at 340 rpm)
min clearance over the cycle: 1.2 mm at 72.55s
...
  scenario baseline         ok
  scenario nok_once         ok
  scenario nok_twice        refused — timed out after 119.11s; programs waiting at: assemble/halt0, driver/idle2
  scenario estop_pressed    refused — timed out after 119.11s; programs waiting at: assemble/engage0, driver/idle0 — forced: panel/estop=true
  scenario feeder_empty     refused — timed out after 119.11s; programs waiting at: assemble/feed0, driver/idle0
  scenario start_wire_open  refused — timed out after 119.11s; programs waiting at: assemble/run0, driver/idle0 — forced: driver/start=false
wrote the document set to cover_bolting_deliverables/ (14 files hashed in the report)

A UR5e with a gripper and a screwdriver on one wrist, the screwdriver on a cover screw

The joint as the drawing states it

The housing's side of the joint is six threaded holes and two dowels; the cover's side is six clearance holes and two dowel holes; between them a screw, a torque and the thread engagement the joint needs. bt.assembly.joint takes exactly that and derives the rest — a frame per hole, the seat, the tightening order, the engagement per hole:

        screw = A.iso4762(5, length_mm)
        threads = A.BoltPattern(
            [A.Hole(f"h{i}", xy, "threaded", diameter_mm=5.0, depth_mm=THREAD_DEPTH_MM) for i, xy in enumerate(HOLES_MM)],
            [A.Locator(f"p{i}", xy, "pin", DOWEL[0] * 1e3, DOWEL[1] * 1e3) for i, xy in enumerate(DOWELS_MM)],
        )
        clearances = A.BoltPattern(
            [A.Hole(f"h{i}", xy, "clearance", diameter_mm=5.5, grip_mm=COVER[2] * 1e3) for i, xy in enumerate(HOLES_MM)],
            [A.Locator(f"p{i}", xy, "hole", DOWEL[0] * 1e3 + 0.1, 10.0) for i, xy in enumerate(DOWELS_MM)],
        )
        joint = A.joint(scene, "cover_joint", a=work.housing, b=work.cover, pattern_a=threads, pattern_b=clearances,
                        fastener=screw, seat=work.seat, thickness=COVER[2],
                        torque_nm=TORQUE_NM, min_engagement_mm=MIN_ENGAGEMENT_MM)

The figures are checked before anything moves: bt.assembly.check compares the screw with the holes and the torque with the tool, and --length 16 is refused at build — an M5×16 through a 10 mm cover engages 6 mm of thread where the joint needs 10. With --catalog the same joint is read from the workpiece pack's mounting block instead, and no hole coordinate is typed in the script (ordering from the catalog).

Two controllers, one handshake

The driver is a program of its own — bt.assembly.driver writes it: on start it finds the drive, runs the screw down for the time M5×0.8 takes at 340 rpm, pre-tightens, final-tightens at 40 rpm and answers ok or nok; two fault_* lanes let a scenario make it answer NOK. Its I/O node models the handshake while the arm's program runs on the UR control box ordered from its pack. The driver's DI/DO addresses are abstract simulation channels: the purchased OnRobot configuration requires an externally powered Compute Box and robot integration, as described in the product notes. An E-stop signal on the operator panel guards both simulated programs:

    panel = appearance.panel(scene, PANEL_AT, ROBOT, bench_top=bz, catalog=catalog_ref(PANEL_CATALOG, catalog_root))
    estop = panel.sensors[0]

    # -- the two controllers: the arm's box from its pack; the driver's
    # handshake uses abstract simulation channels, not a Compute Box pinout.
    # See the product notes for the physical connection route. ----------------
    bt.parts.controller(scene, "controller", robots=[ROBOT], position=CONTROLLER_AT,
                        catalog=CONTROLLER_CATALOG, programs=["assemble"], cable_m=6)
    driver = A.driver(scene, "driver", robot=ROBOT, bit=BIT, shank=SHANK, stroke=stroke, fastener=joint.fastener,
                      torque_nm=TORQUE_SET, cycles=len(joint.order) + RETRY, rpm_run=rpm, tool=tool,
                      estop=estop, channels=bt.io.channels("di", 8, "DI") + bt.io.channels("do", 8, "DO"))
    appearance.compute_box(scene, driver.node, (1.38, 0.22, bz))
    scene.add_spin("driver/spin", driver.signal("run"), ROBOT, link=BIT)

Once the programs are written, Scene.auto_assign_io gives every wire of the handshake an address on its node — the I/O list and the PLCopen export carry them, and the report's I/O row reads 0 unbound:

    wiring = scene.auto_assign_io(["assemble", driver.program])
    if wiring.errors():
        raise ValueError("wiring: " + "; ".join(f.message for f in wiring.errors()))

The program

The arm's program is two calls. place fits the cover — approach, descend, close, attach, carry, seat, release — and declares that the cover is meant to meet the housing, its dowels and the bench (touch), so setting it down is not a collision. fasten appends the six screws in the joint's order: request one, pick it with the bit, carry it over its hole, engage, raise start, ramp the shank down the screw's length while the driver runs, wait for the answer. after=[placement.seated] is the first interlock — the first screw is picked only once the cover's release has raised its seated signal:

    S = bt.seq
    finger = scene.robot_of(ROBOT).joint_names[-1]
    sq = scene.sequence("assemble")
    placement = A.place(sq, joint.b, motions={
        "approach": "cover_approach", "down": "cover_down", "up": "cover_up",
        "carry": "cover_carry", "seat": "cover_seat", "clear": "cover_clear",
    }, close=close, open={finger: 0.0}, expected=joint.seat, touch=touch, robot=ROBOT)
    fastening = A.fasten(sq, joint, driver, feeder, motions={
        "to_pick": "screw_to_pick", "pick": "screw_pick", "lift": "screw_lift",
        "over": "screw_over_{hole}", "engage": "screw_engage_{hole}", "clear": "screw_clear_{hole}",
    }, retry=RETRY, robot=ROBOT, after=[placement.seated])
    sq.step("home", actions=[S.motion("home"), S.set_signal(driver.signal("finish"))])
    return placement, fastening

On the interlock table the two guards read as rows, not as conventions — the first pick waits for the cover seated and a screw present, and no start goes to the driver while the E-stop is in:

| to_pick0 | motion `motion screw_to_pick (arm)` | `(feeder/present AND assemble/cover_seated)` | feed0 | ... |
| start0   | signal `driver/start := TRUE`       | `(DONE(screw_engage_h5) AND NOT panel/estop)` | engage0 | panel/estop (sensor) |

The FAT rows

Each fault is a scenario, and the run of each must refuse the cycle — stall at a named step — rather than let it through. A NOK once is retried and passes; a NOK twice halts the cell with the screw released where it stands; the E-stop in admits no start; an empty presenter presents nothing; a broken START wire leaves the driver waiting for a signal the arm did raise:

    scene.add_scenario("nok_once", signals={driver.signal("fault_first"): True})
    scene.add_scenario("nok_twice", signals={driver.signal("fault_first"): True, driver.signal("fault_retry"): True})
    scene.add_scenario("estop_pressed", faults=[bt.io.stuck(estop, True)])
    # The presenter's magazine empty — its screws are in the reject tray —
    # so a request presents nothing.
    scene.add_scenario("feeder_empty", obstacles={
        s: (REJECT_XY[0], REJECT_XY[1] + 0.02 * i, bz) for i, s in enumerate(feeder.screws)})
    # The START wire to the driver broken: the arm raises it, the driver
    # never hears it.
    scene.add_scenario("start_wire_open", faults=[bt.io.open(driver.signal("start"))])

The scenario table of the report is the FAT sheet: 2/6 passed, and for each refused row the step the programs were waiting at and the fault that was forced.

The hand-over set

deliver writes the set next to the USD — the layout sheet, the bill (six screws as one line with a count), the I/O list, the handshake specification, the PLCopen file with the driver's program on a resource of its own, the interlock table, the tightening sheet (export_sheet), the fastening report as JSON — and then the cell report, which hashes them and carries an assembly section of its own (report_section):

    rows = A.fastening_report(tl, fastening)
    fit = A.fit_report(tl, placement)
    write("cover_bolting.botrail", scene.save_project)
    write("cover_bolting.py", lambda p: p.write_text(scene.generate_python()))
    write("cover_bolting_bom.csv", scene.export_bom)
    write("cover_bolting_bom.md", scene.export_bom)
    write("cover_bolting_io.csv", scene.export_io_list)
    write("cover_bolting_topology.mmd", scene.export_topology)
    write("cover_bolting_handshake.md", tl.export_handshake_spec)
    write("cover_bolting.plcopen.xml", lambda p: scene.export_plcopen(p, name="cover bolting"))
    write("cover_bolting_interlocks.md", scene.export_interlocks)
    write("cover_bolting_layout.svg", lambda p: scene.export_layout(p, scale=120, title="cover bolting"))
    write("cover_bolting_layout.dxf", lambda p: scene.export_layout(p, title="cover bolting"))
    write("cover_bolting_tightening.md", lambda p: A.export_sheet(p, fastening, rows=rows, title="cover bolting"))
    write("cover_bolting_tightening.csv", lambda p: A.export_sheet(p, fastening, rows=rows))
    write("cover_bolting_fastening.json", lambda p: p.write_text(json.dumps({"fit": fit, "screws": rows}, indent=2)))
    runs = scene.simulate_scenarios(["assemble", fastening.driver.program], max_duration=tl.duration + 30.0)
    report = scene.cell_report({"baseline": tl}, scenarios=runs, deliverables=files, title="cover bolting",
                               sections=[A.report_section(fastening, rows, fit)])
    report.save(out / "cover_bolting_report.md")
    report.save(out / "cover_bolting_report.json")
    return report, runs, rows

The tightening sheet is one line per screw in the order it is driven, the design figures beside the baked ones:

| order | hole | screw  | thread | length_mm | class | head     | engagement_mm | torque_nm | rundown_s | step | offset_mm | seated_mm | drive_s | attempts | result |
| 1     | h5   | screw0 | M5x0.8 | 20        | 8.8   | ISO 4762 | 10.0          | 4.50      | 3.75      | run0 | 0.01      | 20.0      | 4.46    | 1        | ok     |

and the report's assembly section states the joint, the driver's timing and the fit above the same rows:

## Assembly

Joint `cover_joint`: `set/cover` onto `set/housing`, 6 × ISO 4762 M5x20-8.8, order h5, h4, h0, h2, h1, h3.
Torque 4.00–5.00 N·m, driver `driver` set to 4.50 N·m (tool 0.15–5 N·m); thread engagement 10.0 mm against 10.0 mm needed; 1 retry allowed, then `assemble/halted`.
Driver time per screw 4.65 s: find 0.20 s, run 3.75 s (17 mm at 340 rpm, pitch 0.8), pre-tighten 0.20 s, final 0.50 s (0.33 turn at 40 rpm).
Fit of `set/cover`: off 0.00 mm, -0.00 mm along the seat, tilt 0.00° — position pass, tilt pass, seated pass.
Baked result: 6/6 screws driven.

All of it is also cover_bolting_report.json, so a regression test reads the joint the way it reads the footprint:

scene, tl, joint, driver, feeder, placement, fastening = demo.bake()
report, runs, rows = demo.deliver(scene, tl, fastening, placement, out_dir)
assert all(r["result"] == "ok" for r in report.sections[0]["json"]["screws"])
assert set(runs.errors) == {"nok_twice", "estop_pressed", "feeder_empty", "start_wire_open"}
assert report.io["unbound"] == 0

What the check refuses

Two more runs are worth making. --misalign 2 teaches the last screw 2 mm off its hole: the engage move is collision-checked and the screw is allowed inside the cover only within its hole's window, so the bake refuses it by name — step 115 (assemble/engage5): ... collision: screw5 x set/cover. And --catalog takes the presenter, screws and housing-and-cover set from their packs. Both modes use the same OnRobot tooling: the bill records catalog ids for catalogued parts and product numbers and sources for the authored references. The requirements table checks the tool's torque, screw length and thread range against the screws it drives (the guide).