Verify the cell in CI¶
Walks through python/tests/test_cell_regression.py
— the regression suite this repository runs against its own cell, and the
pattern to copy into yours.
The bake is deterministic: the same scene produces a bit-identical timeline every run. That is not a performance footnote — it is what turns cell numbers into test assertions. This tutorial reads a real test file that exercises the idea end to end. It runs on the primitive-geometry arm from the checkout, no downloads:
test_cell_cycle_regression PASSED
test_bake_is_deterministic_in_process PASSED
test_layout_change_shifts_the_cycle_deterministically PASSED
3 passed in 0.39s
0.39 seconds for three full cell simulations — cheap enough to run on every commit.
The cell under test¶
The same conveyor-beam-approach cell as Your first cell, written as a function of its layout:
def build_cell(beam_x: float = 0.0) -> bt.Scene:
scene = bt.Scene(bt.Robot.from_urdf(EXAMPLES / "assets" / "simple_arm.urdf"))
scene.add_box("crate", (0.04, 0.04, 0.04), (-0.5, 0.6, 0.3))
scene.add_conveyor(
"belt",
zone_position=(-0.2, 0.6, 0.3),
zone_size=(1.2, 0.3, 0.3),
velocity=(0.25, 0.0, 0.0),
running=False,
)
scene.add_beam_sensor("eye", frm=(beam_x, 0.4, 0.3), to=(beam_x, 0.8, 0.3))
scene.define_signal("present")
scene.add_segment("approach", goal=[0.6, -0.5, 0.8, 0.0, 0.4, 0.0])
scene.add_segment("home", goal=HOME)
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"), bt.seq.set_signal("present")])
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"), bt.seq.set_signal("present", False)],
)
return scene
build_cell(beam_x=...) is the whole trick. A cell authored as a function can
be baked at any variant — which is what the third test below does.
What to assert¶
The main test walks through every kind of check a timeline supports. Taking them in order:
A golden cycle, and a budget — two different assertions:
GOLDEN_CYCLE = 7.45
CYCLE_BUDGET = 8.0
assert tl.duration == pytest.approx(GOLDEN_CYCLE, abs=0.25)
assert tl.duration <= CYCLE_BUDGET
The golden catches change ("this edit moved the cycle"); the budget catches regression ("the cycle no longer fits the takt"). The tolerance is worth reading carefully — as the file's comment puts it, it absorbs libm-level drift between machines, not behavior changes: a replan that adds a detour shifts the cycle by far more than 0.25 s.
The process happened, in order:
Sensor timing, against an analytic value:
feed = tl.step_span("feed")
assert tl.signal("eye").rising_edges() == [feed.end]
assert feed.end == pytest.approx(1.9, abs=0.011)
The crate travels 0.475 m at 0.25 m/s = 1.9 s, quantized up to one 10 ms scan
tick — hence abs=0.011, one tick plus change. When you can compute the
expected number from the layout, do: this assertion documents the physics of
the cell, not just its history.
Handshakes as waveform spans:
assert tl.signal("belt").high_spans() == [(0.0, feed.end)]
assert tl.signal("present").high_spans() == [
(tl.step_span("stop").start, tl.step_span("home").start)
]
Devices are signal lanes too — signal("belt") is the conveyor's running
state. So "the belt ran exactly through feed" and "present covers stop→work"
are one-line checks.
Clearance, over the whole cycle:
min_clearance() samples the tightest
robot-to-environment approach across the cycle — a measure the rollout itself
never takes. pair names the touching links only while in contact, so
pair is None is the "and nothing ever touched" half of the check.
The cycle ends where it should:
Determinism is exact, so test it exactly¶
def test_bake_is_deterministic_in_process() -> None:
scene = build_cell()
a = scene.simulate_sequence("cycle")
b = scene.simulate_sequence("cycle")
# Bit-identical, not approximately equal.
assert a.duration == b.duration
assert a.step_spans == b.step_spans
assert a.signals == b.signals
for t in (0.0, 1.5, 3.0, a.duration):
assert a.sample(t) == b.sample(t)
==, not approx. Two bakes of the same scene are bit-identical — durations,
step spans, signal edges, and every sampled configuration. If this test ever
fails, something nondeterministic crept into the pipeline, and every other
golden in the suite is on notice.
A layout edit becomes a diff¶
def test_layout_change_shifts_the_cycle_deterministically() -> None:
# Move the beam 0.25 m downstream: the crate needs exactly one more
# second at 0.25 m/s, and nothing else about the cell changes. This is
# the §8 workflow in miniature — a layout edit shows up as a cycle-time
# diff a test can catch.
base = build_cell().simulate_sequence("cycle")
moved = build_cell(beam_x=0.25).simulate_sequence("cycle")
assert moved.duration - base.duration == pytest.approx(1.0, abs=0.021)
assert moved.step_span("feed").end - base.step_span("feed").end == pytest.approx(
1.0, abs=0.021
)
Move the beam 0.25 m downstream and the crate needs exactly one more second at 0.25 m/s — nothing else about the cell changes, and the test asserts precisely that. This is the workflow in miniature: a layout edit shows up as a cycle-time diff a test can catch, before it surprises the shop floor.
Running it in your CI¶
The suite is ordinary pytest against the published wheel, so the workflow is two steps:
jobs:
cell:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- run: pip install botrail pytest
- run: python -m pytest tests/ -q
Keep the golden values in the test file, next to the tolerance and the comment explaining it. When a deliberate layout change moves the cycle, the failing test is the review artifact: the diff updates the golden, and the reviewer sees exactly what the edit cost.
The complete test file¶
python/tests/test_cell_regression.py
"""The cell regression test — the operating form of "決定的に焼ける".
This file is the pattern a botrail user copies into their own CI: author
the cell once, bake it, and assert the numbers that matter (cycle time,
step deadlines, signal handshakes, clearance). A layout edit that changes
the cycle then fails a test instead of surprising the shop floor — the
workflow DESIGN.md §8 names as the success condition at cell granularity,
run here against botrail's own repository.
The cell: a crate rides a conveyor into a photoelectric beam, the belt
stops, the arm approaches, works, and comes home. Everything is primitive
geometry from the repo checkout — no downloads.
"""
from pathlib import Path
import pytest
import botrail as bt
EXAMPLES = Path(__file__).resolve().parents[2] / "examples"
HOME = [0.0, 0.0, 0.0, 0.0, 0.0, 0.0]
# Baked on the pinned dependency set; the tolerance absorbs libm-level
# drift between machines, not behavior changes (a replan that adds a
# detour shifts the cycle by far more than 0.25 s).
GOLDEN_CYCLE = 7.45
CYCLE_BUDGET = 8.0
def build_cell(beam_x: float = 0.0) -> bt.Scene:
scene = bt.Scene(bt.Robot.from_urdf(EXAMPLES / "assets" / "simple_arm.urdf"))
scene.add_box("crate", (0.04, 0.04, 0.04), (-0.5, 0.6, 0.3))
scene.add_conveyor(
"belt",
zone_position=(-0.2, 0.6, 0.3),
zone_size=(1.2, 0.3, 0.3),
velocity=(0.25, 0.0, 0.0),
running=False,
)
scene.add_beam_sensor("eye", frm=(beam_x, 0.4, 0.3), to=(beam_x, 0.8, 0.3))
scene.define_signal("present")
scene.add_segment("approach", goal=[0.6, -0.5, 0.8, 0.0, 0.4, 0.0])
scene.add_segment("home", goal=HOME)
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"), bt.seq.set_signal("present")])
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"), bt.seq.set_signal("present", False)],
)
return scene
def test_cell_cycle_regression() -> None:
tl = build_cell().simulate_sequence("cycle")
# The cycle and its budget.
assert tl.duration == pytest.approx(GOLDEN_CYCLE, abs=0.25)
assert tl.duration <= CYCLE_BUDGET
# The process happened in order.
assert [name for name, _, _ in tl.step_spans] == [
"feed",
"stop",
"approach",
"work",
"home",
]
# The beam trips at the analytic time: 0.475 m of travel at 0.25 m/s,
# quantized up to one 10 ms scan tick.
feed = tl.step_span("feed")
assert tl.signal("eye").rising_edges() == [feed.end]
assert feed.end == pytest.approx(1.9, abs=0.011)
# Handshakes: the belt runs exactly through feed; `present` covers
# stop → work and is clear again by the end of the cycle.
assert tl.signal("belt").high_spans() == [(0.0, feed.end)]
assert tl.signal("present").high_spans() == [
(tl.step_span("stop").start, tl.step_span("home").start)
]
# The work dwell holds its spec.
assert tl.step_span("work").duration == pytest.approx(0.5, abs=0.011)
# The swing keeps its distance from the stopped crate for the whole
# cycle — a measure the rollout itself never takes.
clearance = tl.min_clearance()
assert clearance > 0.3
assert clearance.pair is None
# The cycle ends back home.
assert tl.sample(tl.duration) == pytest.approx(HOME, abs=1e-6)
def test_bake_is_deterministic_in_process() -> None:
scene = build_cell()
a = scene.simulate_sequence("cycle")
b = scene.simulate_sequence("cycle")
# Bit-identical, not approximately equal.
assert a.duration == b.duration
assert a.step_spans == b.step_spans
assert a.signals == b.signals
for t in (0.0, 1.5, 3.0, a.duration):
assert a.sample(t) == b.sample(t)
def test_layout_change_shifts_the_cycle_deterministically() -> None:
# Move the beam 0.25 m downstream: the crate needs exactly one more
# second at 0.25 m/s, and nothing else about the cell changes. This is
# the §8 workflow in miniature — a layout edit shows up as a cycle-time
# diff a test can catch.
base = build_cell().simulate_sequence("cycle")
moved = build_cell(beam_x=0.25).simulate_sequence("cycle")
assert moved.duration - base.duration == pytest.approx(1.0, abs=0.021)
assert moved.step_span("feed").end - base.step_span("feed").end == pytest.approx(
1.0, abs=0.021
)
Next¶
Parameter sweeps run the same loop as a study — many variants, one table — and feed the budgets you assert here.