diff --git a/parol6/client/async_client.py b/parol6/client/async_client.py index d00dc20..ac41cd1 100644 --- a/parol6/client/async_client.py +++ b/parol6/client/async_client.py @@ -15,8 +15,9 @@ import msgspec import numpy as np from waldoctl import RobotClient as _RobotClientABC, Shape, ShapeWorld, ToolStatus +from msgspec.structs import asdict from waldoctl.shapes import shape_from_wire -from waldoctl.status import ActionState, ActivityResult, ToolResult +from waldoctl.status import ActionState, ActivityResult, LoopStatsResult, ToolResult from waldoctl.tools import ToolSpec from .. import config as cfg @@ -657,13 +658,21 @@ async def _request_ok_raw(self, data: bytes, timeout: float) -> OkMsg: # --------------- Motion / Control --------------- async def home( - self, wait: bool = False, timeout: float = 60.0, **wait_kwargs: Any + self, + wait: bool = False, + calibrate: bool = False, + timeout: float = 60.0, + **wait_kwargs: Any, ) -> int: """Home the robot to its home position. - Unhomed, this runs the full referencing sequence (each joint seeks - its limit switch, then moves to standby). Already homed, it returns - to standby with a normal planned, collision-checked joint move. + Uncalibrated (first home after power-on), this runs the full + referencing sequence: each joint seeks its limit switch, then the + robot moves to standby. Calibrated, it returns to standby with a + normal planned, collision-checked joint move — unless + ``calibrate=True``, which re-runs the referencing sequence. The + referencing sequence is firmware-driven and ignores the collision + world, so clear keep-out geometry from the joints' sweep first. Returns the command index (≥ 0) on success, -1 on failure. @@ -674,9 +683,10 @@ async def home( Args: wait: If True, block until motion completes + calibrate: If True, always run the referencing sequence timeout: Maximum time to wait in seconds (only used when wait=True) """ - index = await self._send(HomeCmd()) + index = await self._send(HomeCmd(calibrate=calibrate)) assert isinstance(index, int) if wait and index >= 0: ok = await self.wait_command(index, timeout=timeout) @@ -891,7 +901,7 @@ async def status(self) -> StatusResultStruct | None: resp = await self._request(StatusCmd()) return resp if isinstance(resp, StatusResultStruct) else None - async def loop_stats(self) -> LoopStatsResultStruct | None: + async def loop_stats(self) -> LoopStatsResult | None: """Fetch control-loop runtime metrics. Category: Query @@ -900,7 +910,16 @@ async def loop_stats(self) -> LoopStatsResultStruct | None: stats = rbt.loop_stats() """ resp = await self._request(LoopStatsCmd()) - return resp if isinstance(resp, LoopStatsResultStruct) else None + if not isinstance(resp, LoopStatsResultStruct): + return None + # No fieldbus and no real-time scheduling on this backend. + return LoopStatsResult( + **asdict(resp), + can_frame_age_min_ticks=0, + can_frame_age_max_ticks=0, + rt_fifo=False, + rt_pinned=False, + ) async def reset_loop_stats(self) -> int: """Reset control-loop min/max metrics and overrun count. diff --git a/parol6/client/dry_run_client.py b/parol6/client/dry_run_client.py index f89378a..a60c6fb 100644 --- a/parol6/client/dry_run_client.py +++ b/parol6/client/dry_run_client.py @@ -250,7 +250,7 @@ def _snap_to_angles(self, angles_deg: list[float]) -> DryRunResult: def _dispatch(self, params: Any) -> DryRunResult | None: """Route a command struct through the trajectory planner.""" if isinstance(params, HomeCmd): - if not self._planner.state.Homed_in[:6].all(): + if params.calibrate or not self._planner.state.Homed_in[:6].all(): return self._snap_to_angles(HOME_ANGLES_DEG) # Already referenced → fall through: the planner fast-paths HOME # into a planned return move, so the preview renders the path. diff --git a/parol6/client/sync_client.py b/parol6/client/sync_client.py index 8e3d058..8c2448f 100644 --- a/parol6/client/sync_client.py +++ b/parol6/client/sync_client.py @@ -14,12 +14,11 @@ from waldoctl.tools import ToolSpec from waldoctl import PingResult, ToolStatus -from waldoctl.status import ActivityResult, ToolResult +from waldoctl.status import ActivityResult, LoopStatsResult, ToolResult from waldoctl.types import Axis, Frame from ..protocol.wire import ( EnablementResultStruct, - LoopStatsResultStruct, StatusBuffer, StatusResultStruct, ) @@ -175,20 +174,27 @@ def port(self) -> int: # ---------- motion / control ---------- - def home(self, wait: bool = False, timeout: float = 60.0) -> int: + def home( + self, wait: bool = False, calibrate: bool = False, timeout: float = 60.0 + ) -> int: """Home the robot to its home position. - Unhomed, this runs the full referencing sequence (each joint seeks - its limit switch, then moves to standby). Already homed, it returns - to standby with a normal planned, collision-checked joint move. + Uncalibrated (first home after power-on), this runs the full + referencing sequence: each joint seeks its limit switch, then the + robot moves to standby. Calibrated, it returns to standby with a + normal planned, collision-checked joint move — unless + ``calibrate=True``, which re-runs the referencing sequence. The + referencing sequence is firmware-driven and ignores the collision + world, so clear keep-out geometry from the joints' sweep first. Returns the command index (≥ 0) on success, -1 on failure. Args: wait: If True, block until motion completes. + calibrate: If True, always run the referencing sequence. timeout: Maximum time to wait in seconds (only used when wait=True). """ - return _run(self._inner.home(wait=wait, timeout=timeout)) + return _run(self._inner.home(wait=wait, timeout=timeout, calibrate=calibrate)) def teleport( self, @@ -301,11 +307,11 @@ def status(self) -> StatusResultStruct | None: """ return _run(self._inner.status()) - def loop_stats(self) -> LoopStatsResultStruct | None: + def loop_stats(self) -> LoopStatsResult | None: """Control loop runtime statistics. Returns: - LoopStatsResultStruct with loop timing metrics, or None on timeout. + LoopStatsResult with loop timing metrics, or None on timeout. """ return _run(self._inner.loop_stats()) diff --git a/parol6/commands/basic_commands.py b/parol6/commands/basic_commands.py index 8ea10ea..073575c 100644 --- a/parol6/commands/basic_commands.py +++ b/parol6/commands/basic_commands.py @@ -77,8 +77,10 @@ class HomeState(Enum): class HomeCommand(MotionCommand[HomeCmd]): """ A non-blocking command that tells the robot to perform its internal homing sequence. - Reached only while the robot is unhomed — the planner routes HOME from an - already-referenced robot to a planned return move instead. + Reached while the robot is unhomed, or on HOME(calibrate=True) from a + referenced robot — the planner routes plain HOME from an already-referenced + robot to a planned return move instead. The firmware clears the homed bits + when the sequence starts, which WAITING_FOR_UNHOMED relies on. """ PARAMS_TYPE = HomeCmd @@ -97,6 +99,7 @@ def __init__(self, p: HomeCmd): def execute_step(self, state: "ControllerState") -> ExecutionStatusCode: """Manages the homing command and monitors for completion using a state machine.""" + state.homing_step = self.state.value if self.state == HomeState.START: logger.debug( " -> Sending home signal (100)... Countdown: %d", diff --git a/parol6/commands/query_commands.py b/parol6/commands/query_commands.py index 80e6353..7918ee6 100644 --- a/parol6/commands/query_commands.py +++ b/parol6/commands/query_commands.py @@ -176,6 +176,8 @@ def compute(self, state: "ControllerState") -> bytes: p95_period_s=state.p95_period_s, p99_period_s=state.p99_period_s, mean_hz=mean_hz, + p50_period_s=state.p50_period_s, + p90_period_s=state.p90_period_s, ) ) diff --git a/parol6/protocol/wire.py b/parol6/protocol/wire.py index 204f90f..772e882 100644 --- a/parol6/protocol/wire.py +++ b/parol6/protocol/wire.py @@ -16,6 +16,7 @@ import logging from dataclasses import dataclass, field +from collections.abc import Sequence from enum import IntEnum, auto from typing import Annotated, TypeAlias, Union, cast @@ -500,9 +501,15 @@ def __post_init__(self) -> None: class HomeCmd( msgspec.Struct, tag=int(CmdType.HOME), array_like=True, frozen=True, gc=False ): - """HOME: [CmdType.HOME]""" + """HOME: [CmdType.HOME, calibrate] - pass + calibrate=True always runs the firmware's end-stop referencing sequence + to re-derive joint zero. Otherwise an already-referenced robot gets a + planned, collision-checked return move instead; the referencing sequence + itself is firmware-driven and ignores the collision world. + """ + + calibrate: bool = False class ResetCmd( @@ -1009,6 +1016,8 @@ class LoopStatsResultStruct( p95_period_s: float p99_period_s: float mean_hz: float + p50_period_s: float = 0.0 + p90_period_s: float = 0.0 class ToolResultStruct( @@ -1322,6 +1331,9 @@ def pack_response(result: Response) -> bytes: return _encoder.encode(ResponseMsg(result)) +_NO_JOINTS_HOMED: tuple[int, ...] = (0, 0, 0, 0, 0, 0) + + def pack_status( pose: np.ndarray, angles: np.ndarray, @@ -1347,6 +1359,9 @@ def pack_status( scene_epoch: int = 0, accepted_index: int = -1, homed: bool = True, + enabled: bool = True, + homing_step: int = 0, + joints_homed: Sequence[int] = _NO_JOINTS_HOMED, ) -> bytes: """Pack a status broadcast message. @@ -1391,6 +1406,9 @@ def pack_status( scene_epoch, accepted_index, homed, + enabled, + homing_step, + joints_homed, ), option=ormsgpack.OPT_SERIALIZE_NUMPY, ) @@ -1438,6 +1456,25 @@ class StatusBuffer: # All joints homed. True from producers that predate the field (permissive: # old servers gate nothing, so claiming unhomed would be a false alarm). homed: bool = True + # Whether the controller accepts motion (False while disabled, e.g. the + # e-stop latch). True from producers that predate the field. + enabled: bool = True + # Remaining waldoctl StatusBuffer Protocol members. parol6 has no torque + # sensing, fieldbus, or warning source, so these hold their empty values. + torques: np.ndarray = field(default_factory=lambda: np.zeros(6, dtype=np.float64)) + torques_ext: np.ndarray = field( + default_factory=lambda: np.zeros(6, dtype=np.float64) + ) + warnings: list[tuple] = field(default_factory=list) + link_health: dict = field(default_factory=dict) + # Firmware referencing progress in waldoctl's shape: active, sequence_step, + # and one (HomingJointState, HomingPhase) pair per joint; empty while idle. + homing: dict = field(default_factory=dict) + # Last decoded (step, per-joint bits) so the view is rebuilt only on change. + _homing_step: int = field(default=0, init=False, repr=False, compare=False) + _homing_bits: list[int] = field( + default_factory=lambda: [0] * 6, init=False, repr=False, compare=False + ) # Built once in __post_init__, aliasing the two enable arrays the decoder # mutates in place. cart_en: dict[str, np.ndarray] = field(init=False, repr=False, compare=False) @@ -1448,6 +1485,15 @@ def __post_init__(self) -> None: "TRF": self.cart_en_trf, } + @property + def freedrive(self) -> bool: + """PAROL6 steppers cannot be back-driven.""" + return False + + @property + def mode(self) -> ActionState: + return self.action_state + def copy(self) -> "StatusBuffer": """Return a deep copy with all arrays copied.""" ts = self.tool_status @@ -1484,9 +1530,48 @@ def copy(self) -> "StatusBuffer": scene_epoch=self.scene_epoch, accepted_index=self.accepted_index, homed=self.homed, + enabled=self.enabled, + torques=self.torques.copy(), + torques_ext=self.torques_ext.copy(), + warnings=list(self.warnings), + link_health=dict(self.link_health), + homing=dict(self.homing), ) +class HomingJointState(IntEnum): + """Per-joint firmware referencing state (StatusBuffer.homing["joints"]).""" + + SEEKING = 0 + HOMED = 1 + + +class HomingPhase(IntEnum): + """PAROL6 firmware exposes no sub-phase; kept for the (state, phase) shape.""" + + NONE = 0 + + +_HOMING_JOINT_VIEW = ( + (HomingJointState.SEEKING, HomingPhase.NONE), + (HomingJointState.HOMED, HomingPhase.NONE), +) + + +def _apply_homing_progress(buf: StatusBuffer, step: int, bits: list[int]) -> None: + """Rebuild the homing view only when the step or per-joint bits change.""" + if step == buf._homing_step and bits == buf._homing_bits: + return + buf._homing_step = step + buf._homing_bits[:] = bits + if step == 0: + buf.homing.clear() + return + buf.homing["active"] = True + buf.homing["sequence_step"] = step + buf.homing["joints"] = [_HOMING_JOINT_VIEW[b] for b in bits] + + def decode_status_bin_into(data: bytes, buf: StatusBuffer) -> bool: """Zero-allocation decode of STATUS message into preallocated buffer. @@ -1496,7 +1581,7 @@ def decode_status_bin_into(data: bytes, buf: StatusBuffer) -> bool: error, queued_segments, queued_duration, action_params, tool_status_tuple, tcp_speed, simulator_active, collision_active, collision_pairs, scene_epoch, - accepted_index, homed] + accepted_index, homed, enabled, homing_step, joints_homed] Args: data: Raw msgpack bytes @@ -1568,6 +1653,9 @@ def decode_status_bin_into(data: bytes, buf: StatusBuffer) -> bool: buf.scene_epoch = int(msg[22]) buf.accepted_index = int(msg[23]) if len(msg) > 23 else -1 buf.homed = bool(msg[24]) if len(msg) > 24 else True + buf.enabled = bool(msg[25]) if len(msg) > 25 else True + if len(msg) > 27: + _apply_homing_progress(buf, int(msg[26]), msg[27]) return True except Exception as e: @@ -1799,6 +1887,8 @@ def unpack_rx_frame_into( "MoveSCmd", "MovePCmd", "HomeCmd", + "HomingJointState", + "HomingPhase", "CheckpointCmd", # Command structs — streaming (servo/jog) "ServoJCmd", diff --git a/parol6/server/controller.py b/parol6/server/controller.py index 293f771..2d1adac 100644 --- a/parol6/server/controller.py +++ b/parol6/server/controller.py @@ -358,6 +358,8 @@ def _handle_estop(self, state: ControllerState) -> None: self._executor.clear_queue("E-Stop activated") state.Command_out = CommandCode.DISABLE state.Speed_out.fill(0) + state.enabled = False + state.disabled_reason = "E-STOP pressed" state.error = make_error(ErrorCode.SYS_ESTOP_ACTIVE) elif state.InOut_in[4] == 1: # E-stop released if self.estop_active: @@ -464,6 +466,8 @@ def _sync_timer_metrics(self, state: ControllerState) -> None: state.max_period_s = m.max_period_s state.p95_period_s = m.p95_period_s state.p99_period_s = m.p99_period_s + state.p90_period_s = m.p90_period_s + state.p50_period_s = m.p50_period_s def _log_periodic_status(self, state: ControllerState) -> None: """Log performance metrics every 3 seconds.""" diff --git a/parol6/server/loop_timer.py b/parol6/server/loop_timer.py index 613a0ec..f022b5c 100644 --- a/parol6/server/loop_timer.py +++ b/parol6/server/loop_timer.py @@ -80,14 +80,15 @@ def _compute_phase_stats( @njit(cache=True) def _compute_loop_stats( samples: np.ndarray, scratch: np.ndarray, n: int -) -> tuple[float, float, float, float, float, float]: +) -> tuple[float, float, float, float, float, float, float, float]: """Compute loop stats via single-pass Welford for mean+std. Uses the pre-allocated scratch buffer for percentiles (no hot-path alloc): - one copy to scratch, p99 first then p95 on the same data. + one copy to scratch, then p99, p95, p90, p50 in descending order on the + same data. """ if n == 0: - return 0.0, 0.0, 0.0, 0.0, 0.0, 0.0 + return 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0 mean = 0.0 m2 = 0.0 # sum of squared differences @@ -117,11 +118,16 @@ def _compute_loop_stats( k95 = int(n * 0.95) p95 = _quickselect(scratch[:n], k95) + + k90 = int(n * 0.90) + p90 = _quickselect(scratch[:n], k90) + + k50 = int(n * 0.50) + p50 = _quickselect(scratch[:n], k50) else: - p95 = max_val - p99 = max_val + p95 = p99 = p90 = p50 = max_val - return mean, std, min_val, max_val, p95, p99 + return mean, std, min_val, max_val, p95, p99, p90, p50 @njit(cache=True) @@ -514,6 +520,8 @@ class LoopMetrics: "max_period_s", "p95_period_s", "p99_period_s", + "p90_period_s", + "p50_period_s", # Overshoot tracking (how much we miss the deadline by) "mean_overshoot_s", "max_overshoot_s", @@ -543,6 +551,8 @@ def __init__(self) -> None: self.max_period_s = 0.0 self.p95_period_s = 0.0 self.p99_period_s = 0.0 + self.p90_period_s = 0.0 + self.p50_period_s = 0.0 self.mean_overshoot_s = 0.0 self.max_overshoot_s = 0.0 self.p99_overshoot_s = 0.0 @@ -634,7 +644,7 @@ def record_overshoot(self, overshoot: float) -> None: def compute_stats(self) -> None: """Compute statistics from buffers.""" if self._buffer_count > 0: - mean, std, min_val, max_val, p95, p99 = _compute_loop_stats( + mean, std, min_val, max_val, p95, p99, p90, p50 = _compute_loop_stats( self._buffer, self._scratch, self._buffer_count ) self.mean_period_s = mean @@ -643,6 +653,8 @@ def compute_stats(self) -> None: self.max_period_s = max_val self.p95_period_s = p95 self.p99_period_s = p99 + self.p90_period_s = p90 + self.p50_period_s = p50 # overshoot only needs mean/max/p99, so reuse the simpler phase stats if self._overshoot_count > 0: @@ -668,6 +680,8 @@ def reset_stats(self, include_counters: bool = False) -> None: self.max_period_s = 0.0 self.p95_period_s = 0.0 self.p99_period_s = 0.0 + self.p90_period_s = 0.0 + self.p50_period_s = 0.0 self._overshoot_buffer.fill(0.0) self._overshoot_idx = 0 self._overshoot_count = 0 diff --git a/parol6/server/motion_planner.py b/parol6/server/motion_planner.py index 25e687a..b502a85 100644 --- a/parol6/server/motion_planner.py +++ b/parol6/server/motion_planner.py @@ -235,8 +235,13 @@ def process(self, params: object, command_index: int = 0) -> list[Segment]: # Fast-path home: an already-referenced robot returns to the standby # pose with a normal planned (collision-checked) joint move instead - # of re-running the firmware switch-seek. - if isinstance(params, HomeCmd) and bool(self.state.Homed_in[:6].all()): + # of re-running the firmware switch-seek, unless calibration was + # requested explicitly. + if ( + isinstance(params, HomeCmd) + and not params.calibrate + and bool(self.state.Homed_in[:6].all()) + ): params = MoveJCmd(angles=self._home_deg, speed=self._home_return_speed) cmd_class = self._registry.get_command_for_struct(type(params)) diff --git a/parol6/server/state.py b/parol6/server/state.py index 49a7bd4..3a0f8c8 100644 --- a/parol6/server/state.py +++ b/parol6/server/state.py @@ -241,6 +241,10 @@ class ControllerState: # Action tracking for status broadcast and queries action_current: str = "" action_params: str = "" + # HomeState value of the live HomeCommand (1 signalling, 2 waiting for the + # firmware to clear the homed bits, 3 waiting for every joint); meaningful + # only while action_current is "HomeCommand". + homing_step: int = 0 action_state: ActionState = ActionState.IDLE # IDLE, EXECUTING, ERROR action_next: str = "" queue_nonstreamable: list[str] = field(default_factory=list) @@ -292,6 +296,8 @@ class ControllerState: max_period_s: float = 0.0 p95_period_s: float = 0.0 p99_period_s: float = 0.0 + p90_period_s: float = 0.0 + p50_period_s: float = 0.0 # Flag to signal loop stats reset (picked up by controller) loop_stats_reset_pending: bool = False @@ -368,6 +374,7 @@ def reset(self) -> None: # Action tracking self.action_current = "" self.action_params = "" + self.homing_step = 0 self.action_state = ActionState.IDLE self.action_next = "" self.queue_nonstreamable.clear() diff --git a/parol6/server/status_cache.py b/parol6/server/status_cache.py index 28fc870..b3d6278 100644 --- a/parol6/server/status_cache.py +++ b/parol6/server/status_cache.py @@ -166,6 +166,9 @@ def __init__(self) -> None: # All-joints-homed tracking field self._homed: bool = False + self._enabled: bool = True + self._homing_step: int = 0 + self._joints_homed: list[int] = [0] * 6 # Self-collision viz tracking self._collision_active: bool = False @@ -504,16 +507,33 @@ def update_from_state(self, state: ControllerState) -> None: if error_changed: self._error = state.error - # Scalar loop keeps the 100Hz path allocation-free (no slice/temporary). + # One scalar pass keeps the 100Hz path allocation-free; the per-joint + # bits feed both the aggregate `homed` and the homing-progress view. homed = True + homing_changed = False + joints_homed = self._joints_homed for i in range(6): - if not state.Homed_in[i]: + bit = 1 if state.Homed_in[i] else 0 + if not bit: homed = False - break + if joints_homed[i] != bit: + joints_homed[i] = bit + homing_changed = True homed_changed = self._homed != homed if homed_changed: self._homed = homed + enabled_changed = self._enabled != state.enabled + if enabled_changed: + self._enabled = state.enabled + + # Only a live HomeCommand owns homing_step; any cancel path that drops + # the command clears action_current, so derive "idle" from that. + step = state.homing_step if state.action_current == "HomeCommand" else 0 + if self._homing_step != step: + self._homing_step = step + homing_changed = True + collision_changed = ( self._collision_active != state.collision_active or self._collision_pairs != state.collision_pairs @@ -542,6 +562,8 @@ def update_from_state(self, state: ControllerState) -> None: or queue_changed or error_changed or homed_changed + or enabled_changed + or homing_changed or collision_changed or depth_changed ): @@ -577,6 +599,9 @@ def to_binary(self) -> bytes: scene_epoch=self._last_shapes_version, accepted_index=self._accepted_index, homed=self._homed, + enabled=self._enabled, + homing_step=self._homing_step, + joints_homed=self._joints_homed, ) self._binary_dirty = False return self._binary_cache diff --git a/tests/integration/test_unhomed_motion_gate.py b/tests/integration/test_unhomed_motion_gate.py index 6d85346..d177f0c 100644 --- a/tests/integration/test_unhomed_motion_gate.py +++ b/tests/integration/test_unhomed_motion_gate.py @@ -42,3 +42,23 @@ def test_planned_motion_refused_until_homed(client: RobotClient, server_proc): assert client.home(wait=True, timeout=30.0) >= 0 assert client.wait_status(lambda s: s.homed, timeout=2.0) assert client.move_j(target, duration=1.5, wait=True) >= 0 + + +def test_home_calibrate_rereferences_homed_robot(client: RobotClient, server_proc): + """home(calibrate=True) runs the real referencing sequence even when the + robot is already homed — the firmware drops the homed bits while it seeks + the end stops, which a substituted planned return move never does — and + leaves the robot referenced at standby.""" + idx = client.home(calibrate=True) + assert idx >= 0 + assert client.wait_status(lambda s: not s.homed, timeout=5.0) + # Progress is published while the firmware seeks the end stops... + assert client.wait_status( + lambda s: bool(s.homing.get("active")) + and any(state.name == "SEEKING" for state, _ in s.homing["joints"]), + timeout=5.0, + ) + assert client.wait_command(idx, timeout=30.0) + assert client.wait_status(lambda s: s.homed, timeout=2.0) + # ...and the view is empty again once referencing completes. + assert client.wait_status(lambda s: not s.homing, timeout=2.0) diff --git a/tests/unit/test_motion_pipeline.py b/tests/unit/test_motion_pipeline.py index 3d8f77e..8a91212 100644 --- a/tests/unit/test_motion_pipeline.py +++ b/tests/unit/test_motion_pipeline.py @@ -127,6 +127,18 @@ def test_home_routes_by_referenced_state(self, worker, segment_queue): np.testing.assert_allclose(seg.trajectory_steps[-1], home_steps, atol=2) np.testing.assert_allclose(worker.state.Position_in, home_steps, atol=2) + def test_home_calibrate_always_runs_referencing(self, worker, segment_queue): + """HOME(calibrate=True) produces an InlineSegment (the firmware + referencing sequence) even when already referenced — unlike plain + HOME, which fast-paths to a planned return trajectory.""" + worker.state.Position_in[:] = _deg_to_steps(W1) + worker.process_command( + PlanCommand(command_index=0, params=HomeCmd(calibrate=True), homed=True) + ) + seg = segment_queue.get(timeout=1.0) + assert isinstance(seg, InlineSegment) + np.testing.assert_array_equal(worker.state.Position_in, _home_steps()) + def test_checkpoint_produces_inline_segment(self, worker, segment_queue): """Checkpoint should produce an InlineSegment.""" params = CheckpointCmd(label="step1")