Skip to content

Commit ad81cfb

Browse files
committed
Add sim2real timing diagnostics
1 parent 674ce18 commit ad81cfb

2 files changed

Lines changed: 138 additions & 14 deletions

File tree

teleopit/sim2real/controller.py

Lines changed: 116 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,89 @@ class RobotMode(Enum):
6464
DAMPING = "damping" # Emergency stop / recovery
6565

6666

67+
class _LoopTimingReporter:
68+
"""Aggregate best-effort control-loop timing stats and emit periodic logs."""
69+
70+
def __init__(self, *, target_period_s: float, log_interval_s: float = 1.0) -> None:
71+
self._target_period_s = float(target_period_s)
72+
self._log_interval_s = float(log_interval_s)
73+
self._window_start_s: float | None = None
74+
self._loop_ms: list[float] = []
75+
self._work_ms: list[float] = []
76+
self._pico_age_ms: list[float] = []
77+
self._overrun_count = 0
78+
79+
def record(
80+
self,
81+
*,
82+
loop_start_s: float,
83+
work_elapsed_s: float,
84+
cycle_elapsed_s: float,
85+
pico_age_s: float | None,
86+
) -> None:
87+
if self._window_start_s is None:
88+
self._window_start_s = float(loop_start_s)
89+
90+
self._loop_ms.append(float(cycle_elapsed_s) * 1000.0)
91+
self._work_ms.append(float(work_elapsed_s) * 1000.0)
92+
if pico_age_s is not None:
93+
self._pico_age_ms.append(float(pico_age_s) * 1000.0)
94+
if cycle_elapsed_s > self._target_period_s + 1e-9:
95+
self._overrun_count += 1
96+
97+
if loop_start_s - self._window_start_s >= self._log_interval_s:
98+
self._emit(loop_start_s)
99+
100+
def _emit(self, end_s: float) -> None:
101+
sample_count = len(self._loop_ms)
102+
if sample_count <= 0:
103+
self._reset(end_s)
104+
return
105+
106+
loop_summary = self._summarize(self._loop_ms)
107+
work_summary = self._summarize(self._work_ms)
108+
message = (
109+
"Timing stats | samples=%d window=%.1fs | "
110+
"loop_ms p50=%.2f p95=%.2f p99=%.2f max=%.2f overrun=%d/%d | "
111+
"work_ms p50=%.2f p95=%.2f p99=%.2f max=%.2f"
112+
)
113+
args: list[object] = [
114+
sample_count,
115+
end_s - float(self._window_start_s),
116+
loop_summary[0],
117+
loop_summary[1],
118+
loop_summary[2],
119+
loop_summary[3],
120+
self._overrun_count,
121+
sample_count,
122+
work_summary[0],
123+
work_summary[1],
124+
work_summary[2],
125+
work_summary[3],
126+
]
127+
if self._pico_age_ms:
128+
pico_summary = self._summarize(self._pico_age_ms)
129+
message += " | pico_age_ms p50=%.2f p95=%.2f p99=%.2f max=%.2f"
130+
args.extend([pico_summary[0], pico_summary[1], pico_summary[2], pico_summary[3]])
131+
logger.info(message, *args)
132+
self._reset(end_s)
133+
134+
def _reset(self, window_start_s: float) -> None:
135+
self._window_start_s = float(window_start_s)
136+
self._loop_ms.clear()
137+
self._work_ms.clear()
138+
self._pico_age_ms.clear()
139+
self._overrun_count = 0
140+
141+
@staticmethod
142+
def _summarize(samples: list[float]) -> tuple[float, float, float, float]:
143+
values = np.asarray(samples, dtype=np.float64)
144+
if values.size <= 0:
145+
return 0.0, 0.0, 0.0, 0.0
146+
p50, p95, p99 = np.percentile(values, [50.0, 95.0, 99.0])
147+
return float(p50), float(p95), float(p99), float(np.max(values))
148+
149+
67150
def _parse_sim2real_viewers(cfg: Any) -> set[str]:
68151
viewers = parse_viewers(cfg)
69152
unsupported = viewers.difference({"retarget"})
@@ -266,6 +349,7 @@ def run(self) -> None:
266349
"Control loop started | mode=IDLE | press Start to enter STANDING"
267350
)
268351
dt = 1.0 / self.policy_hz
352+
timing = _LoopTimingReporter(target_period_s=dt)
269353

270354
try:
271355
self._video_runtime.start()
@@ -284,22 +368,26 @@ def run(self) -> None:
284368
logger.warning("EMERGENCY STOP (L1+R1)")
285369
self._enter_damping()
286370
self._tick_dexterous_hand()
287-
self._sleep_until(t0, dt)
288-
continue
289-
290-
# 3. Mode transitions
291-
self._handle_transitions()
371+
else:
372+
# 3. Mode transitions
373+
self._handle_transitions()
292374

293-
# 5. Execute current mode
294-
if self.mode == RobotMode.STANDING:
295-
self._standing_step()
296-
elif self.mode == RobotMode.MOCAP:
297-
self._mocap_step()
375+
# 5. Execute current mode
376+
if self.mode == RobotMode.STANDING:
377+
self._standing_step()
378+
elif self.mode == RobotMode.MOCAP:
379+
self._mocap_step()
298380

299-
self._tick_dexterous_hand()
381+
self._tick_dexterous_hand()
300382

301-
# 6. Rate control
302-
self._sleep_until(t0, dt)
383+
work_elapsed_s = time.monotonic() - t0
384+
cycle_elapsed_s = self._sleep_until(t0, dt)
385+
timing.record(
386+
loop_start_s=t0,
387+
work_elapsed_s=work_elapsed_s,
388+
cycle_elapsed_s=cycle_elapsed_s,
389+
pico_age_s=self._sample_pico_frame_age_s(),
390+
)
303391

304392
except KeyboardInterrupt:
305393
logger.info("KeyboardInterrupt -- shutting down")
@@ -944,12 +1032,26 @@ def _write_retarget_viewer(self, qpos: Float64Array) -> None:
9441032
logger.exception("Sim2real retarget viewer update failed; control continues")
9451033

9461034
@staticmethod
947-
def _sleep_until(t0: float, dt: float) -> None:
1035+
def _sleep_until(t0: float, dt: float) -> float:
9481036
"""Sleep to maintain control frequency."""
9491037
elapsed = time.monotonic() - t0
9501038
remaining = dt - elapsed
9511039
if remaining > 0:
9521040
time.sleep(remaining)
1041+
return time.monotonic() - t0
1042+
1043+
def _sample_pico_frame_age_s(self) -> float | None:
1044+
has_frame = getattr(self.input_provider, "has_frame", None)
1045+
get_frame_packet = getattr(self.input_provider, "get_frame_packet", None)
1046+
if not callable(has_frame) or not callable(get_frame_packet):
1047+
return None
1048+
try:
1049+
if not has_frame():
1050+
return None
1051+
_, frame_timestamp_s, _ = get_frame_packet()
1052+
except Exception:
1053+
return None
1054+
return max(0.0, time.monotonic() - float(frame_timestamp_s))
9531055

9541056
# ------------------------------------------------------------------
9551057
# Lifecycle

tests/test_sim2real_runtime.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -316,6 +316,28 @@ def test_sim2real_retarget_viewer_rejects_sim_viewers(monkeypatch) -> None:
316316
Sim2RealController(cfg)
317317

318318

319+
def test_loop_timing_reporter_logs_percentiles_and_overruns(caplog) -> None:
320+
import logging
321+
322+
from teleopit.sim2real.controller import _LoopTimingReporter
323+
324+
reporter = _LoopTimingReporter(target_period_s=0.02, log_interval_s=1.0)
325+
with caplog.at_level(logging.INFO, logger="teleopit.sim2real.controller"):
326+
reporter.record(loop_start_s=0.0, work_elapsed_s=0.005, cycle_elapsed_s=0.020, pico_age_s=0.010)
327+
reporter.record(loop_start_s=0.5, work_elapsed_s=0.006, cycle_elapsed_s=0.021, pico_age_s=0.012)
328+
reporter.record(loop_start_s=1.0, work_elapsed_s=0.007, cycle_elapsed_s=0.050, pico_age_s=0.030)
329+
330+
text = caplog.text
331+
assert "Timing stats" in text
332+
assert "loop_ms p50=" in text
333+
assert "p95=" in text
334+
assert "p99=" in text
335+
assert "max=" in text
336+
assert "overrun=2/3" in text
337+
assert "work_ms p50=" in text
338+
assert "pico_age_ms p50=" in text
339+
340+
319341
def test_sim2real_rejects_nonzero_reference_steps_without_buffer(monkeypatch) -> None:
320342
from teleopit.sim2real.controller import Sim2RealController
321343

0 commit comments

Comments
 (0)