Timeline assertions¶
A SequenceTimeline is everything one cycle did,
queryable. Because the bake is deterministic,
every number it returns can be asserted in CI — this guide is the vocabulary
for doing that.
The timing chart¶
tl.duration # cycle time, seconds
tl.step_spans # [(name, start, end), ...] in execution order
span = tl.step_span("feed") # one step, assertion-friendly
span.start, span.end, span.duration
The one-step view reads the way a spec does:
assert tl.step_span("feed").end <= 2.0 # the part arrives on time
assert tl.step_span("work").duration == pytest.approx(0.5, abs=0.011)
(0.011 = one 10 ms scan tick plus change — the natural tolerance for
anything quantized by the scan.)
Signal lanes¶
Every internal signal, sensor, and device running-state is a waveform:
lane = tl.signal("eye")
lane.edges # [(t, value), ...] starting with (0, initial)
lane.rising_edges() # times it turns ON (the initial level is not an edge)
lane.falling_edges()
lane.high_spans() # [(start, end), ...]; an open interval closes at duration
lane.high_total() # total ON time
lane.value_at(t)
Handshakes become one-liners:
assert tl.signal("eye").rising_edges() == [tl.step_span("feed").end]
assert tl.signal("belt").high_spans() == [(0.0, tl.step_span("feed").end)]
assert tl.signal("carrying").high_total() < 10.0
Handshakes, response times and faults¶
A handshake between two controllers is a signal one program writes and another waits on; the bake has both ends, so its timing is a plain assertion. The pattern is "the reader moves within n scans of the edge":
tl = scene.simulate_sequences(["st1", "st2", "transfer"])
done = tl.signal("st1_done").rising_edges()
gate = tl.step_span("transfer/p2_gate")
assert done[0] <= gate.end <= done[0] + 0.02 # released within two scans
For a robot the controller side has no lane; the timeline synthesizes it:
busy = tl.robot_busy("st1_lh") # [(start, end), ...] merged moves
starts = [t for _, t, _ in tl.moves("st1_lh")]
assert busy[0][0] == starts[0] # busy rises with the first start
assert all(b - a > 0 for a, b in busy)
Two contacts that must never be on together (an interlock) is an intersection test on their high spans:
def overlap(a, b):
return [(max(s1, s2), min(e1, e2)) for s1, e1 in a for s2, e2 in b if max(s1, s2) < min(e1, e2)]
assert overlap(tl.signal("near_in_zone").high_spans(), tl.signal("far_in_zone").high_spans()) == []
And a fault scenario (see the I/O map) turns "what if the wire breaks" into a row of the same table:
scene.add_scenario("beam_open", faults=[bt.io.open("body_at_head")])
runs = scene.simulate_scenarios(["st1", "st2", "transfer"])
assert "forced: body_at_head=false" in runs.errors["beam_open"] # it stops, and says why
assert "transfer/p1_load" in runs.errors["beam_open"] # ... at the step that reads it
The safe-side assertion is the one without an error: an inverted E-stop
wire that still runs is a wiring finding, and the run shows it —
assert "estop_open" in runs.errors is the check that the healthy contact
is wired to fail low. tl.export_handshake_spec(path) writes the whole
interface as a Markdown sheet, per scenario.
Clearance¶
The tightest robot-to-environment approach over the whole cycle, sampled every
dt seconds — carried and conveyed objects replay their baked motion.
Clearance compares and converts like its distance, so:
and when it fails, the repr names the time and the touching pair. clr.t is
when the minimum first happens; clr.pair names the touching
(robot side, obstacle) only while in contact, so clr.pair is None is
the "and nothing ever touched" half of a safety check. Robot-robot contact
never appears here — it is already a hard error during the bake itself.
Robot tracks and object motion¶
tl.sample(t, robot="far") # joint positions at t
tl.moves("far") # [(label, start, end)] — what drove it when
tl.robot_trajectory("far") # the cycle as a Trajectory (CSV/JSON export;
# step boundaries land in segment_ends)
tl.object_pose("crate", t) # where a carried/conveyed part was
tl.object_visible("crate", t) # False only while stowed in a magazine
object_pose is how the tracking tutorial measured its 150 mm of belt
travel between latch and grasp.
Utilization: the line-balancing number¶
tl.utilization("st1_lh") # 0..1 — fraction of the cycle it moved
tl.busy_seconds("st1_lh") # the same in seconds (overlaps merged)
tl.utilizations() # {robot: utilization} for the whole cell
On a line this is the number that decides where work should go: the
bottleneck station is the one whose arms sit highest, and moving a spot off
it is the edit whose effect on takt you can then measure rather than
estimate. examples/welding/line_balance_sweep.py does exactly that — bakes the
real line once per weld-schedule split and prints takt, per-station cycle,
and utilization — and python/tests/test_line_balance.py pins the result,
which is what makes "changing the layout" a regression test. The studio
shows the same figure beside each robot lane on the timing chart.
A useful invariant to assert alongside the takt: on an indexed line, the takt is the transfer plus the slowest station. If that stops holding, the cycle time has stopped meaning what you think it means.
Golden values vs budgets¶
Two different assertions, both worth having:
assert tl.duration == pytest.approx(7.45, abs=0.25) # golden: catches change
assert tl.duration <= 8.0 # budget: catches regression
Per machine, a re-bake is bit-identical — the tolerance on a golden absorbs libm-level drift between machines, not behavior. Size tolerances to what they must absorb: one scan tick for step timing, a quarter second for a full-cycle golden. The Verify the cell in CI tutorial walks a complete suite built this way.