Parameter sweeps¶
Walks through examples/basics/sweep_demo.py
— the cell authored once as a function of its parameters, baked at every
variant with bt.sweep, compared by the numbers that matter, and searched
with bt.optimize.
Because motions are planned rather than taught point by point, a layout change does not invalidate the cell — it just changes the numbers. That makes layout studies a loop: author the cell as a function, sweep the parameter, read the table. No re-teaching between rows. This example runs from a checkout with no downloads (primitive-geometry arm):
The whole script¶
Short enough to read in one sitting:
"""Parameter sweep over a cell — "環境の自由度" made operational.
The cell is authored once as a function of its parameters; every variant
is then baked deterministically and compared by the numbers that matter
(cycle time, sensor timing, clearance). This is the loop behind layout
studies and cycle-time regression: change the environment, re-simulate,
read the diff — no re-teaching. `bt.sweep` runs the grid and tables it;
`bt.optimize` searches it for the best feasible point — deterministically,
without a random number anywhere.
Runs from a checkout with no downloads (primitive-geometry arm):
python examples/basics/sweep_demo.py
"""
from pathlib import Path
import botrail as bt
ASSETS = Path(__file__).resolve().parents[1] / "assets"
def build_cell(velocity: float = 0.25, lane_y: float = 0.6) -> bt.Scene:
"""Conveyor feed → beam stop → approach → work → home, parameterized
by belt speed and by how close the conveyor lane runs to the robot."""
scene = bt.Scene(bt.Robot.from_urdf(ASSETS / "simple_arm.urdf"))
scene.add_box("crate", (0.04, 0.04, 0.04), (-0.5, lane_y, 0.3))
scene.add_conveyor(
"belt",
zone_position=(-0.2, lane_y, 0.3),
zone_size=(1.2, 0.3, 0.3),
velocity=(velocity, 0.0, 0.0),
running=False,
)
scene.add_beam_sensor(
"eye", frm=(0.0, lane_y - 0.2, 0.3), to=(0.0, lane_y + 0.2, 0.3)
)
scene.add_segment("approach", goal=[0.6, -0.5, 0.8, 0.0, 0.4, 0.0])
scene.add_segment("home", goal=[0.0, 0.0, 0.0, 0.0, 0.0, 0.0])
sq = scene.sequence("cycle")
sq.step("feed", actions=[bt.seq.start("belt")], transition=bt.seq.signal("eye"))
sq.step("stop", actions=[bt.seq.stop("belt")])
sq.step("approach", actions=[bt.seq.motion("approach")])
sq.step("work", transition=bt.seq.elapsed(0.5))
sq.step("home", actions=[bt.seq.motion("home")])
return scene
def metrics(tl: bt.SequenceTimeline) -> dict:
"""The three numbers under study, read off one bake."""
return {
"cycle": tl.duration,
"feed": tl.step_span("feed").duration,
"clearance": float(tl.min_clearance()),
}
def bake(velocity: float = 0.25, lane_y: float = 0.6):
"""One variant as `(cycle, feed, clearance)` — the same numbers a
sweep row holds, for a test that wants them by hand."""
m = metrics(build_cell(velocity, lane_y).simulate_sequence("cycle"))
return m["cycle"], m["feed"], m["clearance"]
def main() -> None:
print("== belt speed sweep (lane_y = 0.60 m) ==")
speed = bt.sweep(
build_cell,
grid={"velocity": [0.10, 0.15, 0.20, 0.25, 0.30, 0.35], "lane_y": [0.6]},
metrics=metrics,
sequence="cycle",
)
print(speed.to_markdown())
print("-> only the feed wait moves; the motion part of the cycle is fixed\n")
print("== conveyor lane sweep (belt = 0.25 m/s) ==")
lane = bt.sweep(
build_cell,
grid={"velocity": [0.25], "lane_y": [0.70, 0.60, 0.50, 0.40, 0.35]},
metrics=metrics,
sequence="cycle",
)
print(lane.to_markdown())
print("-> the cycle barely moves, the safety margin is what shrinks\n")
print("== both at once: cycle time over the grid ==")
both = bt.sweep(
build_cell,
grid={"velocity": [0.15, 0.25, 0.35], "lane_y": [0.7, 0.5, 0.35]},
metrics=metrics,
sequence="cycle",
)
print(both.pivot("lane_y", "velocity", "cycle"))
print("(clearance over the same grid)")
print(both.pivot("lane_y", "velocity", "clearance"))
print("== the question a layout meeting asks: fastest cycle with 0.4 m of clearance ==")
best = bt.optimize(
build_cell,
space={"velocity": (0.10, 0.40, 0.05), "lane_y": (0.30, 0.70, 0.05)},
objective="cycle",
constraints={"clearance": (">=", 0.4)},
metrics=metrics,
sequence="cycle",
method="descent",
)
print(f"{best.params} -> cycle {best.row['cycle']:.2f} s, clearance {best.row['clearance']:.2f} m "
f"({len(best.evaluated)} bakes, coordinate descent; the full grid is 63)")
print("\nEvery row above is a deterministic bake: re-running this script")
print("prints the same numbers, which is what makes them assertable in CI")
print("(see python/tests/test_cell_regression.py).")
if __name__ == "__main__":
main()
Everything hangs off the signature build_cell(velocity, lane_y): belt speed,
and how close the conveyor lane runs to the robot. metrics(tl) reduces one
bake to the three numbers under study — cycle time, feed duration, minimum
clearance — and bt.sweep does the rest: it calls
build_cell at every point of the grid, bakes the named sequence, applies
metrics, and hands back a table (Sweep) whose rows
are the parameters plus the numbers, in grid order. A variant the planner
cannot solve is a row with ok=False and the reason, not an exception —
the table says where the cliff is.
The output¶
== belt speed sweep (lane_y = 0.60 m) ==
| velocity | lane_y | cycle | feed | clearance |
|---|---|---|---|---|
| 0.1 | 0.6 | 10.30 | 4.76 | 0.530 |
| 0.15 | 0.6 | 8.71 | 3.17 | 0.530 |
| 0.2 | 0.6 | 7.92 | 2.38 | 0.530 |
| 0.25 | 0.6 | 7.44 | 1.90 | 0.530 |
| 0.3 | 0.6 | 7.13 | 1.59 | 0.530 |
| 0.35 | 0.6 | 6.90 | 1.36 | 0.530 |
-> only the feed wait moves; the motion part of the cycle is fixed
== conveyor lane sweep (belt = 0.25 m/s) ==
| velocity | lane_y | cycle | feed | clearance |
|---|---|---|---|---|
| 0.25 | 0.7 | 7.44 | 1.90 | 0.630 |
| 0.25 | 0.6 | 7.44 | 1.90 | 0.530 |
| 0.25 | 0.5 | 7.45 | 1.91 | 0.430 |
| 0.25 | 0.4 | 7.45 | 1.91 | 0.330 |
| 0.25 | 0.35 | 7.45 | 1.91 | 0.280 |
-> the cycle barely moves, the safety margin is what shrinks
== both at once: cycle time over the grid ==
| lane_y \ velocity | 0.15 | 0.25 | 0.35 |
|---|---|---|---|
| 0.7 | 8.71 | 7.44 | 6.90 |
| 0.5 | 8.71 | 7.45 | 6.90 |
| 0.35 | 8.71 | 7.45 | 6.90 |
(clearance over the same grid)
| lane_y \ velocity | 0.15 | 0.25 | 0.35 |
|---|---|---|---|
| 0.7 | 0.630 | 0.630 | 0.630 |
| 0.5 | 0.430 | 0.430 | 0.430 |
| 0.35 | 0.280 | 0.280 | 0.280 |
== the question a layout meeting asks: fastest cycle with 0.4 m of clearance ==
{'velocity': 0.4, 'lane_y': 0.5} -> cycle 6.73 s, clearance 0.43 m (13 bakes, coordinate descent; the full grid is 63)
Reading the tables¶
The two sweeps fail in opposite ways, which is the lesson:
- Belt speed moves the cycle. The whole difference between 10.30 s and 6.90 s is the feed wait — the planned motions are untouched. If the cell misses takt, this column says whether a faster belt buys it back.
- Lane position eats the clearance. The cycle barely moves (the approach
is a hair longer), but the safety margin drops linearly — at
lane_y = 0.35the closest approach over the whole cycle is down to 0.28 m. Nothing failed yet, which is exactly why it is worth a number: this is the regression a visual check misses.
Every row is a deterministic bake — re-running the script prints the same table, digit for digit.
From sweep to test¶
A sweep tells you where the cliff is; a test keeps you off it. The two assertions this study feeds, in the vocabulary of Verify the cell in CI:
def test_takt_at_nominal_speed():
tl = build_cell(velocity=0.25).simulate_sequence("cycle")
assert tl.duration <= 8.0 # from the speed table
def test_lane_keeps_its_margin():
tl = build_cell(lane_y=0.60).simulate_sequence("cycle")
assert tl.min_clearance() > 0.5 # 0.530 nominal, with headroom
Move the lane 100 mm closer in a layout revision and the second test fails with the new clearance in the message — the sweep row, delivered as a red build.
Two axes at once, and the search¶
The third block of the output is the same study over both parameters:
Sweep.pivot(rows, cols, metric) folds a two-axis grid into one table per
metric, and the two tables say the whole story at a glance — velocity moves
the cycle, lane moves the clearance, and neither touches the other.
The last block is the question a layout meeting actually asks: the
fastest cycle that still keeps 0.4 m of clearance. bt.optimize
searches the space for it — as a full grid, or, as here, by coordinate
descent on the grid (from the middle of the space, one step of each
parameter at a time, taking the best feasible improvement until nothing
improves): 13 bakes instead of 63, the same answer, and every bake it made
is in best.evaluated as a table. Both methods are deterministic — there is
no random number anywhere in a study — so the optimum is as assertable as a
single cell:
best = bt.optimize(build_cell, space={"velocity": (0.10, 0.40, 0.05), "lane_y": (0.30, 0.70, 0.05)},
objective="cycle", constraints={"clearance": (">=", 0.4)},
metrics=metrics, sequence="cycle", method="descent")
assert best.params == {"velocity": 0.4, "lane_y": 0.5}
Scaling it up¶
A study is a table, and the table saves: result.save("study.csv") (or
.md, .json) for whichever plotting tool the layout meeting uses,
result.best("cycle", where=lambda r: r["clearance"] >= 0.4) for the row
that matters, result.pareto(minimize=["cycle"], maximize=["clearance"])
for the trade-off front. Bigger grids bake in parallel — workers=4 runs
the variants in separate processes and still returns the rows in grid order
(build and metrics then have to be importable, module-level functions,
as they are in this file).
Next¶
The same discipline holds with two robots in the cell — and the numbers get more interesting: Two arms, one belt.