Skip to content

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:

python -m pytest python/tests/test_cell_regression.py -v
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:

assert [name for name, _, _ in tl.step_spans] == ["feed", "stop", "approach", "work", "home"]

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:

clearance = tl.min_clearance()
assert clearance > 0.3
assert clearance.pair is None

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:

assert tl.sample(tl.duration) == pytest.approx(HOME, abs=1e-6)

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.