The clock that lied
We added one boring check to the analysis, "are there gaps in the data?", and it unravelled a bug in the machine, not the code. It also proved that an earlier diagnosis of ours was wrong.
A boring check fails
To finish Phase 1, battery_report.py got a data completeness check: no gap longer than 3 s between /battery samples in Elasticsearch. We expected it to pass trivially. It failed on our "good" run from Experiment #1:
| Data completeness (max gap) | <= 3 s | 5.4 s | FAIL |
Follow the data down the stack
Suspect 1: the shipper drops data. We have the ground truth, so we checked the MCAP bag directly (analysis/bag_gaps.py):
header stamp: 546 msgs over 294.0s; top gaps: [3.44, 3.43, 3.43, 3.43, 3.43]
The gaps were in the bag too, so the shipper was innocent. And they were suspiciously identical: 3.43 s, every ~33 s.
Suspect 2: the node stalls. We ran battery_sim alone with a control: a plain Python loop doing sleep(0.5) and timing itself with the monotonic clock. The control never stalled once. The node "stalled" on schedule.
The arithmetic that cracked it: 546 messages at 2 Hz is 273 s of ticks, but the timestamps spanned 294 s. The node wasn't missing ticks. Time itself was jumping forward between them.
Catching the clock in the act
$ python3 scripts/clock_check.py 75
t+ 8.5s wall clock stepped +2.932s
t+ 40.8s wall clock stepped +2.931s
t+ 73.1s wall clock stepped +2.930s
wall-vs-monotonic drift over 75s: +8.793s
$ cat /sys/devices/system/clocksource/clocksource0/current_clocksource
tsc # WSL2 normally uses hyperv_clocksource_tsc_page
The WSL update that killed our first session installed kernel 6.18, and it booted with the tsc clocksource. Linux's monotonic clock runs about 9% slow on it. Time sync notices and steps the wall clock forward about 2.9 s every 32 s. ROS timestamps use wall time; timers use the slow monotonic clock.
We had it wrong earlier
In Phase 1 we blamed a 6.7% drain error on "Python timers firing late". We were wrong. The 40 s smoke run contained one clock step (about 3 s out of about 43 s), and that was the error. Measuring dt from the clock was still the right fix, for the wrong reason. The old post now carries a correction.
Making it observable, forever
| Where | What |
|---|---|
make doctor |
Fails if the clocksource isn't hyperv_clocksource_tsc_page |
make clock-check |
Measures wall-clock steps over 40 s |
es_shipper stats |
Reports clock_offset_s (wall minus monotonic) every 10 s, charted in Kibana |
| Analysis | The completeness check stays: it flags untrustworthy timestamps, which matter as much as missing data |
The host fix needs root, so it's left for the human:
# Immediate (until the next WSL restart):
echo hyperv_clocksource_tsc_page | sudo tee /sys/devices/system/clocksource/clocksource0/current_clocksource
# Persistent: in Windows, %UserProfile%\.wslconfig
# [wsl2]
# kernelCommandLine = clocksource=hyperv_clocksource_tsc_page
Update: the clocksource wasn't it
Chris switched to hyperv_clocksource_tsc_page, and the steps continued: still +2.93 s every 32 s.
Measured against the Windows clock over 60 s: Windows 65.82 s, Linux wall 66.18 s,
Linux monotonic 60.32 s. The kernel's timekeeping runs ~8.4% slow under every clocksource,
and systemd-timesyncd (polling at its 32 s minimum) steps the wall clock to compensate.
The likely culprit is the new WSL kernel itself. make doctor now measures the clock rate against
Windows (scripts/clock_rate.py) instead of trusting the clocksource name. A check that passes
while the problem persists is worse than no check.
Lessons learned
- Keep the raw data. Without the MCAP bag we would have "fixed" the shipper.
- Run a control. One
sleep()loop separated "the node" from "the machine". - Do the arithmetic. Message count × period vs. timestamp span exposed the clock.
- Correct the record. A devlog that hides its wrong turns teaches the wrong lessons.
- For a robot, time is data. Gazebo will run on simulated time, but anything stamped with wall time inherits the host's clock bugs.