Skip to content

Commit b776ea3

Browse files
authored
Merge pull request #38 from PCrnjak/claude/par6-backend-setup-q9l7bx
Broadcast the control loop's own health with STATUS
2 parents 4d83eaa + 961c6b6 commit b776ea3

3 files changed

Lines changed: 104 additions & 2 deletions

File tree

‎parol6/protocol/wire.py‎

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
Wire format uses msgpack arrays with integer type codes:
1010
- OK: MsgType.OK (just the integer)
1111
- ERROR: [MsgType.ERROR, message]
12-
- STATUS: [MsgType.STATUS, pose, angles, speeds, io, action_current, action_state, joint_en, cart_en_wrf, cart_en_trf, executing_index, completed_index, last_checkpoint, error, queued_segments, queued_duration, action_params, tool_status, tcp_speed, simulator_active, collision_active, collision_pairs, scene_epoch, accepted_index, homed]
12+
- STATUS: [MsgType.STATUS, pose, angles, speeds, io, action_current, action_state, joint_en, cart_en_wrf, cart_en_trf, executing_index, completed_index, last_checkpoint, error, queued_segments, queued_duration, action_params, tool_status, tcp_speed, simulator_active, collision_active, collision_pairs, scene_epoch, accepted_index, homed, enabled, homing_step, joints_homed, loop_health]
1313
- RESPONSE: [MsgType.RESPONSE, query_type, value]
1414
- COMMAND: [CmdType.XXX, ...params]
1515
"""
@@ -1362,6 +1362,8 @@ def pack_status(
13621362
enabled: bool = True,
13631363
homing_step: int = 0,
13641364
joints_homed: Sequence[int] = _NO_JOINTS_HOMED,
1365+
p99_period_s: float = 0.0,
1366+
overruns: int = 0,
13651367
) -> bytes:
13661368
"""Pack a status broadcast message.
13671369
@@ -1409,6 +1411,7 @@ def pack_status(
14091411
enabled,
14101412
homing_step,
14111413
joints_homed,
1414+
(p99_period_s, overruns),
14121415
),
14131416
option=ormsgpack.OPT_SERIALIZE_NUMPY,
14141417
)
@@ -1467,6 +1470,15 @@ class StatusBuffer:
14671470
)
14681471
warnings: list[tuple] = field(default_factory=list)
14691472
link_health: dict = field(default_factory=dict)
1473+
# The drives report per-joint error FLAGS over the serial link, not
1474+
# analog temperature or current registers, so there is nothing to put
1475+
# here: empty is what tells a consumer "no such sensor" rather than
1476+
# "all zero". Flags surface as faults, not as a trend.
1477+
drive_health: dict = field(default_factory=dict)
1478+
# The control loop's own health: p99_period_s and overruns. Empty from
1479+
# producers that predate the field, which is how a consumer tells
1480+
# "loop healthy" from "loop not reported".
1481+
loop_health: dict = field(default_factory=dict)
14701482
# Firmware referencing progress in waldoctl's shape: active, sequence_step,
14711483
# and one (HomingJointState, HomingPhase) pair per joint; empty while idle.
14721484
homing: dict = field(default_factory=dict)
@@ -1535,6 +1547,8 @@ def copy(self) -> "StatusBuffer":
15351547
torques_ext=self.torques_ext.copy(),
15361548
warnings=list(self.warnings),
15371549
link_health=dict(self.link_health),
1550+
drive_health=dict(self.drive_health),
1551+
loop_health=dict(self.loop_health),
15381552
homing=dict(self.homing),
15391553
)
15401554

@@ -1581,7 +1595,8 @@ def decode_status_bin_into(data: bytes, buf: StatusBuffer) -> bool:
15811595
error, queued_segments, queued_duration, action_params,
15821596
tool_status_tuple, tcp_speed, simulator_active,
15831597
collision_active, collision_pairs, scene_epoch,
1584-
accepted_index, homed, enabled, homing_step, joints_homed]
1598+
accepted_index, homed, enabled, homing_step, joints_homed,
1599+
loop_health]
15851600
15861601
Args:
15871602
data: Raw msgpack bytes
@@ -1656,6 +1671,12 @@ def decode_status_bin_into(data: bytes, buf: StatusBuffer) -> bool:
16561671
buf.enabled = bool(msg[25]) if len(msg) > 25 else True
16571672
if len(msg) > 27:
16581673
_apply_homing_progress(buf, int(msg[26]), msg[27])
1674+
if len(msg) > 28:
1675+
lh = msg[28]
1676+
buf.loop_health = {
1677+
"p99_period_s": float(lh[0]),
1678+
"overruns": int(lh[1]),
1679+
}
16591680

16601681
return True
16611682
except Exception as e:

‎parol6/server/status_cache.py‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,8 @@ def __init__(self) -> None:
166166

167167
# All-joints-homed tracking field
168168
self._homed: bool = False
169+
self._p99_period_s: float = 0.0
170+
self._overruns: int = 0
169171
self._enabled: bool = True
170172
self._homing_step: int = 0
171173
self._joints_homed: list[int] = [0] * 6
@@ -550,6 +552,17 @@ def update_from_state(self, state: ControllerState) -> None:
550552
self._queued_segments = state.queued_segments
551553
self._queued_duration = state.queued_duration
552554

555+
# The percentile is recomputed once per stats window and overruns
556+
# are rare, so this re-encodes the cached payload about as often as
557+
# the window turns rather than on every tick.
558+
loop_changed = (
559+
self._p99_period_s != state.p99_period_s
560+
or self._overruns != state.overrun_count
561+
)
562+
if loop_changed:
563+
self._p99_period_s = state.p99_period_s
564+
self._overruns = state.overrun_count
565+
553566
# Mark binary cache dirty if anything changed
554567
if (
555568
pos_changed
@@ -566,6 +579,7 @@ def update_from_state(self, state: ControllerState) -> None:
566579
or homing_changed
567580
or collision_changed
568581
or depth_changed
582+
or loop_changed
569583
):
570584
self._binary_dirty = True
571585

@@ -602,6 +616,8 @@ def to_binary(self) -> bytes:
602616
enabled=self._enabled,
603617
homing_step=self._homing_step,
604618
joints_homed=self._joints_homed,
619+
p99_period_s=self._p99_period_s,
620+
overruns=self._overruns,
605621
)
606622
self._binary_dirty = False
607623
return self._binary_cache
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
"""The control loop's own health rides the status broadcast.
2+
3+
A display that wants to say whether the loop is keeping up should not have
4+
to poll ``loop_stats()`` for it: the period tail and the deadline-miss
5+
count are on every STATUS frame, and they are the live numbers rather than
6+
a boot-time snapshot.
7+
"""
8+
9+
import asyncio
10+
11+
import pytest
12+
13+
from parol6 import AsyncRobotClient
14+
15+
16+
@pytest.mark.asyncio
17+
@pytest.mark.integration
18+
async def test_status_carries_the_loops_own_health(server_proc, ports):
19+
"""STATUS reports the loop percentile and overrun count, and they agree
20+
with what the LOOP_STATS query answers about the same loop."""
21+
async with AsyncRobotClient(port=ports.server_port) as client:
22+
assert await client.wait_ready(timeout=10.0)
23+
24+
# The overrun count only ever climbs, and comparing one sample of
25+
# it against another taken at a different moment says nothing
26+
# unless the order of the two is known. So: read it, then wait for
27+
# a frame that has caught up to that reading, then read it again.
28+
# The frame is then known to sit between the two, and a loaded
29+
# runner missing a deadline in between is the loop being honest
30+
# rather than the broadcast disagreeing with the query.
31+
before = await client.loop_stats()
32+
assert before is not None
33+
34+
seen: dict = {}
35+
36+
async def collect() -> None:
37+
async for status in client.stream_status_shared():
38+
health = dict(getattr(status, "loop_health", {}) or {})
39+
# The percentile needs a full sampling window before it
40+
# means anything, and the shared buffer may still hold a
41+
# frame from before the reading above — wait past both
42+
# rather than taking whichever frame arrives first.
43+
if (
44+
health.get("p99_period_s", 0.0) > 0.0
45+
and health.get("overruns", -1) >= before.overrun_count
46+
):
47+
seen.update(health)
48+
return
49+
50+
try:
51+
await asyncio.wait_for(collect(), timeout=15.0)
52+
except asyncio.TimeoutError:
53+
pytest.fail("no loop health ever arrived on STATUS")
54+
55+
after = await client.loop_stats()
56+
assert after is not None
57+
assert abs(seen["p99_period_s"] - after.p99_period_s) < 5e-3, (
58+
f"STATUS says p99 {seen['p99_period_s']}, "
59+
f"the query says {after.p99_period_s}"
60+
)
61+
assert seen["overruns"] <= after.overrun_count, (
62+
f"STATUS says {seen['overruns']} overruns, past the "
63+
f"{after.overrun_count} a query taken after it reports — the "
64+
"broadcast is not reading the same counter"
65+
)

0 commit comments

Comments
 (0)