Skip to content

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):

python examples/basics/sweep_demo.py

The whole script

Short enough to read in one sitting:

examples/basics/sweep_demo.py
"""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.35 the 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.

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.