Skip to content

Commit 427756f

Browse files
committed
Merge commit '8fb7f26cb80b78ee4f0e1b41ca1b011f40d93340' into feat/tcp-calibration
2 parents 9348393 + 8fb7f26 commit 427756f

10 files changed

Lines changed: 223 additions & 21 deletions

File tree

‎parol6/client/async_client.py‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -968,7 +968,11 @@ async def status_rate(self) -> StatusRate | None:
968968
resp = await self._request(StatusRateCmd())
969969
if not isinstance(resp, StatusRateResultStruct):
970970
return None
971-
return StatusRate(hz=resp.hz, control_hz=resp.control_hz)
971+
return StatusRate(
972+
hz=resp.hz,
973+
control_hz=resp.control_hz,
974+
servable=tuple(float(v) for v in resp.servable),
975+
)
972976

973977
async def select_tool(self, tool_name: str, variant_key: str = "") -> int:
974978
"""Set the active end-effector tool on the controller.

‎parol6/client/dry_run_client.py‎

Lines changed: 64 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@
3838
import re as _re
3939

4040
import parol6.protocol.wire as _wire
41+
from waldoctl.commands import CommandKind, command_table
4142
from ..protocol.wire import (
4243
HomeCmd,
4344
SelectToolCmd,
@@ -78,6 +79,9 @@ def _pascal_to_snake(name: str) -> str:
7879

7980
_UPPER_FIELDS: frozenset[str] = frozenset({"tool_name", "tool_key", "profile"})
8081

82+
_COMMANDS = command_table()
83+
_AXIS_INDEX: dict[str, int] = {"X": 0, "Y": 1, "Z": 2, "RX": 3, "RY": 4, "RZ": 5}
84+
8185

8286
def build_cmd(name: str, *args: Any, **kwargs: Any) -> Any:
8387
"""Build a command struct by method name."""
@@ -601,6 +605,52 @@ def servo_j(
601605
return self._dispatch(build_cmd("servo_j_pose", pose, **kwargs))
602606
return self._dispatch(build_cmd("servo_j", angles or [], **kwargs))
603607

608+
def jog_j(
609+
self,
610+
joint: int = -1,
611+
speed: float = 0.0,
612+
duration: float = 0.1,
613+
*,
614+
joints: list[int] | None = None,
615+
speeds: list[float] | None = None,
616+
accel: float = 1.0,
617+
) -> DryRunResult | None:
618+
"""The live client's signature, so a script's jog previews as written."""
619+
speed_arr = [0.0] * 6
620+
if joints is not None and speeds is not None:
621+
for j, s in zip(joints, speeds):
622+
speed_arr[j] = s
623+
elif joint >= 0:
624+
speed_arr[joint] = speed
625+
else:
626+
raise ValueError("jog_j requires either joint= or joints=/speeds=")
627+
return self._dispatch(
628+
_wire.JogJCmd(speeds=speed_arr, duration=duration, accel=accel)
629+
)
630+
631+
def jog_l(
632+
self,
633+
frame: str,
634+
axis: str | None = None,
635+
speed: float = 0.0,
636+
duration: float = 0.1,
637+
*,
638+
axes: list[str] | None = None,
639+
speeds_list: list[float] | None = None,
640+
accel: float = 1.0,
641+
) -> DryRunResult | None:
642+
vel = [0.0] * 6
643+
if axes is not None and speeds_list is not None:
644+
for a, s in zip(axes, speeds_list):
645+
vel[_AXIS_INDEX[a]] = s
646+
elif axis is not None:
647+
vel[_AXIS_INDEX[axis]] = speed
648+
else:
649+
raise ValueError("jog_l requires either axis= or axes=/speeds_list=")
650+
return self._dispatch(
651+
_wire.JogLCmd(frame=frame, velocities=vel, duration=duration, accel=accel)
652+
)
653+
604654
def delay(self, seconds: float = 0.0) -> None:
605655
pass
606656

@@ -615,8 +665,19 @@ def __getattr__(self, name: str) -> Any:
615665
if name not in _CMD_STRUCTS:
616666
raise AttributeError(f"'{type(self).__name__}' has no attribute '{name}'")
617667

618-
def method(*args: Any, **kwargs: Any) -> DryRunResult | None:
619-
cmd = build_cmd(name, *args, **kwargs)
620-
return self._dispatch(cmd)
668+
spec = _COMMANDS.get(name)
669+
applies = spec is not None and spec.kind in (
670+
CommandKind.SYSTEM,
671+
CommandKind.CONTROL,
672+
)
673+
674+
def method(*args: Any, **kwargs: Any) -> DryRunResult | int | None:
675+
result = self._dispatch(build_cmd(name, *args, **kwargs))
676+
if not applies:
677+
return result
678+
# A system or control command answers as the live client does:
679+
# 1 when it applied, negative when the planner refused it. Its
680+
# planner result carries no path a program could wait on.
681+
return -1 if result is not None and result.error is not None else 1
621682

622683
return method

‎parol6/commands/query_commands.py‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -200,7 +200,11 @@ def compute(self, state: "ControllerState") -> bytes:
200200
return pack_response(
201201
StatusRateResultStruct(
202202
hz=state.status_rate_hz,
203-
control_hz=1.0 / max(cfg.INTERVAL_S, 1e-9),
203+
# The configured rate, not 1/INTERVAL_S: inverting the
204+
# interval adds float noise to a value `achievable()` and the
205+
# divisor arithmetic treat as exact (1/(1/49) is 49.000000001).
206+
control_hz=float(cfg.CONTROL_RATE_HZ),
207+
servable=cfg.servable_status_rates(),
204208
)
205209
)
206210

‎parol6/commands/utility_commands.py‎

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
MotionCommand,
1212
SystemCommand,
1313
)
14-
from parol6.config import CONTROL_RATE_HZ
14+
from parol6.config import CONTROL_RATE_HZ, servable_status_rates
1515
from parol6.protocol.wire import (
1616
CheckpointCmd,
1717
CmdType,
@@ -115,9 +115,7 @@ def execute_step(self, state: "ControllerState") -> ExecutionStatusCode:
115115
# as a generic tick failure instead of the refusal that names the
116116
# rates this controller can serve.
117117
if not (1.0 <= hz <= control) or not hz.is_integer() or control % int(hz) != 0:
118-
allowed = ", ".join(
119-
str(control // n) for n in range(1, control + 1) if control % n == 0
120-
)
118+
allowed = ", ".join(f"{hz:g}" for hz in servable_status_rates())
121119
self.fail(
122120
make_error(
123121
ErrorCode.SYS_STATUS_RATE_INVALID,

‎parol6/config.py‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,11 @@
2222
MAX_COMMAND_QUEUE_SIZE: int = 100
2323
MAX_BLEND_LOOKAHEAD: int = int(os.getenv("PAROL6_MAX_BLEND_LOOKAHEAD", "100"))
2424
MAX_POLL_COUNT: int = 25 # Max UDP messages to read per control tick
25+
# Further messages read in a tick whose batch filled up. A client streaming
26+
# faster than the tick leaves a backlog in the socket; it is already stale, so
27+
# carrying it to later ticks makes the arm chase old targets and delays the
28+
# stop behind them by as many ticks as the backlog is deep.
29+
MAX_BACKLOG_COUNT: int = int(os.getenv("PAROL6_MAX_BACKLOG_COUNT", "500"))
2530

2631
# Serial transport defaults
2732
SERIAL_RX_RING_DEFAULT: int = 262144
@@ -99,6 +104,19 @@ def status_broadcast_interval(hz: float) -> int:
99104
return max(1, int(CONTROL_RATE_HZ) // int(hz))
100105

101106

107+
def servable_status_rates() -> tuple[float, ...]:
108+
"""Broadcast rates this controller accepts, highest first.
109+
110+
Status goes out every Nth control tick, so the servable rates are the
111+
divisors of the control rate. One answer, used by the query that reports
112+
the set and by the refusal that names it.
113+
"""
114+
control = int(CONTROL_RATE_HZ)
115+
return tuple(
116+
float(control // n) for n in range(1, control + 1) if control % n == 0
117+
)
118+
119+
102120
# Validate STATUS_RATE_HZ divides evenly into CONTROL_RATE_HZ for polling
103121
if int(CONTROL_RATE_HZ) % int(STATUS_RATE_HZ) != 0:
104122
raise ValueError(

‎parol6/protocol/wire.py‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1078,10 +1078,13 @@ class StatusRateResultStruct(
10781078
frozen=True,
10791079
gc=False,
10801080
):
1081-
"""Broadcast rate, and the control rate it divides."""
1081+
"""Broadcast rate, the control rate it divides, and the rates the
1082+
controller accepts -- its own answer, so a caller can pick one that will
1083+
be accepted instead of discovering the constraint by rejection."""
10821084

10831085
hz: float
10841086
control_hz: float
1087+
servable: tuple[float, ...] = ()
10851088

10861089

10871090
class LoopStatsResultStruct(

‎parol6/server/controller.py‎

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@
6666
from parol6.config import (
6767
TRACE,
6868
INTERVAL_S,
69+
MAX_BACKLOG_COUNT,
6970
MAX_POLL_COUNT,
7071
MCAST_GROUP,
7172
MCAST_PORT,
@@ -604,11 +605,27 @@ def _main_control_loop(self):
604605
state.Speed_out.fill(0)
605606

606607
def _poll_commands(self, state: ControllerState) -> None:
607-
"""Poll and process UDP commands (non-blocking)."""
608+
"""Poll and process UDP commands (non-blocking).
609+
610+
A full batch means a client outran the tick, so the rest of the socket
611+
is read in this tick as well: each streaming command supersedes the one
612+
before it, so the arm ends the tick on the newest target instead of
613+
following a queue of old ones for as many ticks as the backlog is deep,
614+
and the configuration, queries and stops mixed into it are still seen,
615+
in order -- which a blind socket drain threw away.
616+
"""
608617
assert self.udp_transport is not None
609618

610619
state.command_out_locked = False
611-
msgs = self.udp_transport.poll_receive_all(max_count=MAX_POLL_COUNT)
620+
# Copied: the transport hands back a buffer it reuses on the next call.
621+
msgs = list(self.udp_transport.poll_receive_all(max_count=MAX_POLL_COUNT))
622+
if len(msgs) == MAX_POLL_COUNT:
623+
backlog = self.udp_transport.poll_receive_all(max_count=MAX_BACKLOG_COUNT)
624+
if len(backlog) == MAX_BACKLOG_COUNT:
625+
logger.log(
626+
TRACE, "udp_backlog_capped count=%d", MAX_BACKLOG_COUNT
627+
)
628+
msgs.extend(backlog)
612629
for data, addr in msgs:
613630
self._process_command(data, addr, state)
614631

‎tests/integration/test_status_rate.py‎

Lines changed: 20 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,14 @@ async def test_an_unachievable_rate_is_refused_with_the_rule(server_proc, ports)
104104
assert await client.wait_ready(timeout=10.0)
105105
before = await client.status_rate()
106106
assert before is not None
107+
assert before.servable, (
108+
"the controller knows its divisor set -- it formats it into the "
109+
"refusal -- so the query has to report it rather than leaving the "
110+
"client to re-derive one backend's rule"
111+
)
112+
assert before.achievable() == before.servable
113+
assert before.hz in before.servable
114+
assert before.control_hz == max(before.servable)
107115
achievable = before.achievable()
108116

109117
for bogus in (0.0, -50.0, 0.5, 62.5, float("nan"), float("inf")):
@@ -116,7 +124,7 @@ async def test_an_unachievable_rate_is_refused_with_the_rule(server_proc, ports)
116124
f"{bogus} Hz came back as {refusal.title!r} rather than as an "
117125
f"unservable rate: {refusal.cause}"
118126
)
119-
unnamed = [hz for hz in achievable if str(int(hz)) not in refusal.remedy]
127+
unnamed = [hz for hz in achievable if f"{hz:g}" not in refusal.remedy]
120128
assert not unnamed, (
121129
f"refusing {bogus} Hz has to say what would work instead, but "
122130
f"{unnamed} are missing from {refusal.remedy!r}"
@@ -150,19 +158,23 @@ def advance() -> float:
150158
return cache.tcp_speed
151159

152160
advance() # first difference has nothing to difference against
153-
at_50 = advance()
154-
assert at_50 > 0.0, "a moving arm has to report a speed"
155-
156-
state.status_rate_hz = 25.0
161+
started = advance()
162+
assert started > 0.0, "a moving arm has to report a speed"
163+
164+
# Halve whatever rate this environment configured, rather than
165+
# assuming the 50 Hz default: the cache reads its period from the
166+
# state, so a shell with PAROL6_STATUS_RATE_HZ set would otherwise
167+
# fail the ratio for reasons that have nothing to do with the code.
168+
state.status_rate_hz = state.status_rate_hz / 2
157169
straddling = advance()
158170
settled = advance()
159171

160-
assert straddling == pytest.approx(at_50, rel=1e-3), (
172+
assert straddling == pytest.approx(started, rel=1e-3), (
161173
"the sample taken before the rate changed spans the old period"
162174
)
163-
assert settled == pytest.approx(at_50 / 2, rel=1e-3), (
175+
assert settled == pytest.approx(started / 2, rel=1e-3), (
164176
"half the broadcast rate is twice the period, so the same "
165-
f"movement per frame is half the speed: {settled} vs {at_50}"
177+
f"movement per frame is half the speed: {settled} vs {started}"
166178
)
167179
finally:
168180
cache.close()

‎tests/unit/test_dry_run_script_compat.py‎

Lines changed: 56 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,9 @@
1111

1212
import numpy as np
1313
import pytest
14+
from waldoctl import CommandKind, command_table
1415

15-
from parol6.client.dry_run_client import DryRunRobotClient
16+
from parol6.client.dry_run_client import _CMD_STRUCTS, DryRunRobotClient
1617

1718
HOME = [90.0, -90.0, 180.0, 0.0, 0.0, 180.0]
1819
POSE_A = [0.0, 280.0, 200.0, 90.0, 0.0, 90.0]
@@ -176,3 +177,57 @@ def test_snap_carries_the_pending_blend_chain(self):
176177
assert len(result.joint_trajectory_rad) > 1
177178
assert np.allclose(np.degrees(result.end_joints_rad), HOME, atol=0.5)
178179
assert client.flush() == []
180+
181+
182+
def test_jogs_take_the_live_clients_arguments(client):
183+
"""`rbt.jog_j(0, 0.5, 1.0)` and `rbt.jog_l("WRF", "X", 0.5, 1.0)` are the
184+
forms the docs show; the preview must plan them, not the wire struct's
185+
field order."""
186+
before = np.asarray(client.angles())
187+
result = client.jog_j(0, 0.5, 1.0)
188+
assert result is not None and result.error is None
189+
after = np.degrees(result.end_joints_rad)
190+
assert after[0] > before[0] + 1.0
191+
assert np.allclose(after[1:], before[1:], atol=1e-6)
192+
193+
client = DryRunRobotClient()
194+
x_before = client.pose()[0]
195+
result = client.jog_l("WRF", "X", 0.5, 1.0)
196+
assert result is not None and result.error is None
197+
assert result.tcp_poses[-1][0] * 1000.0 > x_before + 1.0
198+
assert client.pose()[0] > x_before + 1.0
199+
with pytest.raises(ValueError, match="joint="):
200+
client.jog_j(speed=0.5)
201+
202+
203+
_STATE_ARGS = {
204+
"reset": (),
205+
"reset_state": (),
206+
"set_status_rate": (50,),
207+
"simulator": (True,),
208+
"teleport": (HOME,),
209+
"set_shapes": ([],),
210+
"select_profile": ("RUCKIG",),
211+
"select_tool": ("NONE",),
212+
"set_tcp_offset": (0.0, 0.0, 0.0),
213+
"connect_hardware": ("/dev/null",),
214+
"stop": (),
215+
"estop": (),
216+
}
217+
218+
219+
@pytest.mark.parametrize(
220+
"name",
221+
sorted(
222+
n
223+
for n, s in command_table().items()
224+
if s.kind in (CommandKind.SYSTEM, CommandKind.CONTROL) and n in _CMD_STRUCTS
225+
),
226+
)
227+
def test_state_commands_answer_with_the_live_clients_int_codes(client, name):
228+
"""`if rbt.stop() < 0:` must read the same in preview as on the arm: a
229+
system or control command returns 1/0/negative, never a planner result."""
230+
assert name in _STATE_ARGS, f"add sample arguments for {name}"
231+
result = getattr(client, name)(*_STATE_ARGS[name])
232+
assert isinstance(result, int) and not isinstance(result, bool)
233+
assert result == 1

‎tests/unit/test_reset_enable_reaches_firmware.py‎

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,3 +101,33 @@ def test_streaming_packet_keeps_following_configuration_and_stop(controller):
101101
"streaming must not discard pending configuration"
102102
)
103103
assert not state.enabled, "a queued stop must survive a streaming batch boundary"
104+
105+
106+
def test_a_streaming_flood_is_consumed_in_the_tick_it_arrived_in(controller):
107+
"""A client streaming faster than one tick's batch leaves a backlog in the
108+
socket. It is stale the moment the tick runs, so the tick has to consume
109+
it: otherwise the arm follows superseded targets for backlog/batch ticks
110+
and anything queued behind them — here a stop — waits just as long."""
111+
from parol6.config import MAX_POLL_COUNT
112+
from parol6.protocol.wire import JogJCmd
113+
114+
state = controller.state_manager.get_state()
115+
assert controller.udp_transport is not None
116+
address = ("127.0.0.1", controller.udp_transport.socket.getsockname()[1])
117+
flood = MAX_POLL_COUNT * 4
118+
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sender:
119+
for _ in range(flood):
120+
sender.sendto(
121+
encode_command(
122+
JogJCmd(speeds=[0.1, 0.0, 0.0, 0.0, 0.0, 0.0], duration=0.2)
123+
),
124+
address,
125+
)
126+
sender.sendto(encode_command(EstopCmd()), address)
127+
controller._poll_commands(state)
128+
controller._execute_commands(state)
129+
130+
assert not state.enabled, (
131+
f"one tick left {flood} streamed targets unread, so the stop behind "
132+
f"them was not seen either"
133+
)

0 commit comments

Comments
 (0)