Skip to content

Commit eec4d88

Browse files
Minipadaclaude
andcommitted
feat(tools/sim): measure raw-mode collection volume against the simulated robot
doc/src/dc/raw_topics.md's "How much data is this?" figures came from a hand-written topic emulation. tools/sim/scripts/measure_raw_volume.sh boots dc_simulation's warehouse world headless, drives the Waffle, runs dc_bridge in raw mode against its live topics in three configurations and reports per-Tag Records, bytes, bytes/Record, publish rate and a projected GB/day. Local and informational — not wired into ci.yaml. The world runs at ~4% of real time without a GPU, and raw.max_rate_hz limits Records per *wall* second, so measuring the shipped defaults there measures the box. The profiles therefore collect uncapped, the window is counted in simulated seconds sampled off /clock, and the report multiplies exact bytes-per-Record by min(published_hz, max_rate_hz) — the rate a robot running at real time would ship at. check_raw()'s volume accounting moves to tools/e2e/scripts/raw_volume.py so the verifier and the benchmark report the same thing; ten fixture tests pin it and a standalone raw-volume CI job runs them. Closes #326 Signed-off-by: David Bensoussan <d.bensoussan@proton.me> Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RsF8rCLWfP8u7CvhUcJ3LK
1 parent 8912018 commit eec4d88

13 files changed

Lines changed: 917 additions & 51 deletions

File tree

.github/workflows/ci.yaml

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
# build-workspace (build + colcon test + coverage) ─┬─▶ sim (pull dc-workspace, simulation smoke check)
1717
# └─▶ build-e2e-image ─▶ e2e (pull dc-e2e, zero-loss harness)
1818
# kpi-views (standalone: SQL + a throwaway Postgres, no workspace image needed)
19+
# raw-volume (standalone: stdlib Python fixtures over the shared volume accounting)
1920
#
2021
# e2e calls the same tools/e2e/scripts/run.sh a developer runs locally, driving the
2122
# harness with plain podman (no compose) so nothing extra is installed on the runner;
@@ -312,6 +313,23 @@ jobs:
312313
done
313314
exit 1
314315
316+
# tools/e2e/scripts/raw_volume.py is stdlib Python shared by the zero-loss verifier and
317+
# the sim-backed volume benchmark (#326) — no workspace image, no simulator, no database.
318+
# The benchmark itself is deliberately not a CI job: it boots gz-sim for a number nobody
319+
# asserts a threshold on.
320+
raw-volume:
321+
runs-on: ubuntu-latest
322+
steps:
323+
- uses: actions/checkout@v5
324+
325+
- name: Install uv
326+
run: |
327+
curl -LsSf https://astral.sh/uv/0.12.5/install.sh | sh
328+
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
329+
330+
- name: Fixture tests for the raw-volume accounting
331+
run: uv run --frozen pytest tools/e2e/test -q
332+
315333
# The KPI definitions are SQL, not workspace code: this needs a database and nothing else.
316334
kpi-views:
317335
runs-on: ubuntu-latest

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -280,6 +280,7 @@ cython_debug/
280280
# scratch state, not source.
281281
.ci-ws/
282282
tools/e2e/.run/
283+
tools/sim/.run/
283284

284285
# dc_bridge local core test build dir (fast-iteration cmake, not colcon)
285286
dc_bridge/test/local/build/

dc_simulation/README.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,18 @@ Without it gz-sensors stamps messages with the scoped sensor name, which is not
3333
frame, and every tf2 `MessageFilter` downstream — AMCL, both Nav2 costmaps — silently
3434
drops the message.
3535

36+
## What the robot's topics cost to collect
37+
38+
```bash
39+
./tools/sim/scripts/measure_raw_volume.sh # per-Tag bytes, rates and a GB/day
40+
```
41+
42+
Boots this world headless, drives the robot, runs `dc_bridge` in raw mode against its
43+
topics and reports what each one is worth in stored bytes — the source of the figures in
44+
[Raw topic collection](../doc/src/dc/raw_topics.md#how-much-data-is-this). Local and
45+
informational, never a CI gate; see the script's header for the profiles and why its rates
46+
are counted in simulated seconds.
47+
3648
## Running headless, without a GPU
3749

3850
The rendering sensors (`gpu_lidar` and both `rgbd_camera`s) **do** work with no GPU and

doc/src/dc/raw_topics.md

Lines changed: 60 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -147,9 +147,9 @@ The values above are the defaults, and they matter:
147147
everything twice, under two different Tags.
148148
- **High-rate sensor types are excluded**, by type rather than by name (a camera topic is
149149
not reliably called anything in particular). One 640×480 `sensor_msgs/msg/Image` is
150-
roughly 900 kB of JSON numbers; at 30 Hz nothing downstream is sized for it. Override
151-
the list deliberately if you want them — DC will not stop you, but read the next
152-
section first.
150+
900 kB on the wire and roughly 3.7 MB once every pixel byte is a JSON number; at 30 Hz
151+
nothing downstream is sized for it. Override the list deliberately if you want them —
152+
DC will not stop you, but read the next section first.
153153

154154
To replace a list, write the replacement; note that an *empty* YAML list (`[]`) cannot be
155155
loaded by rclcpp (it has no inferable element type), so use a pattern that matches
@@ -198,38 +198,68 @@ acknowledged (see [Destinations](./destinations.md#delivery-guarantees)).
198198

199199
### How much data is this?
200200

201-
Measured against a TurtleBot3-Waffle-shaped workload — the topic mix
202-
`dc_simulation`'s warehouse world bridges out, at the rates those sensors actually run
203-
(IMU 100 Hz, odom/tf/joint_states 50 Hz, camera 30 Hz, cmd_vel 20 Hz, lidar 10 Hz,
204-
battery 1 Hz) — collected into a `file` Destination:
201+
These figures come off a **simulated robot**, not a spreadsheet:
202+
`tools/sim/scripts/measure_raw_volume.sh` boots `dc_simulation`'s warehouse world, drives
203+
the TurtleBot3-Waffle in a slow circle, points a Bridge in raw mode at its live topics,
204+
and reports what a `file` Destination stored, per Tag. Everything below is one run of it —
205+
re-run it after anything that changes what a Record costs. It is a local tool, not a CI
206+
gate; its header explains the three configurations and why its rates are counted in
207+
*simulated* seconds.
205208

206-
| Configuration | Stored |
209+
Rates are that world's Waffle's: IMU 200 Hz, odometry and TF 30 Hz (the `DiffDrive`
210+
plugin), lidar and both RGBD cameras 5 Hz. `/cmd_vel` and `/clock` are left out — the
211+
first is the benchmark's own driving, the second exists only because the robot is
212+
simulated.
213+
214+
| Configuration | Shipped |
207215
|---|---|
208-
| **Defaults** (10 Hz cap, sensor types excluded) | 19 kB/s — **67 MB/hour, 1.6 GB/day** |
209-
| …plus `scan` and `tf` re-enabled | ≈ 4.3 GB/day |
210-
| …plus the 640×480 camera re-enabled | ≈ **1.6 TB/day** |
216+
| **Defaults** (10 Hz cap, sensor types excluded) | 24 kB/s — **86 MB/hour, 2.1 GB/day** |
217+
| …plus `scan` and `tf` re-enabled | 69 kB/s — **5.9 GB/day** |
218+
| …plus both 1280×720 cameras re-enabled | ≈ **9.5 TB/day**, if anything could carry it |
211219

212220
Per-Record cost, which is what to multiply by your own topics' rates:
213221

214-
| Message | Bytes per Record |
215-
|---|---|
216-
| `geometry_msgs/msg/Twist` | 202 |
217-
| `sensor_msgs/msg/JointState` (2 joints) | 326 |
218-
| `sensor_msgs/msg/Imu` | 488 |
219-
| `nav_msgs/msg/Odometry` | 605 |
220-
| `tf2_msgs/msg/TFMessage` (2 transforms) | 547 |
221-
| `sensor_msgs/msg/LaserScan` (360 ranges + intensities) | 2 585 |
222-
| `sensor_msgs/msg/Image` (640×480 `rgb8`) | **1 843 521** |
223-
224-
The last row is the whole argument for the default type exclusions: one VGA frame costs
225-
as much as ~3 000 odometry Records, and JSON roughly **doubles** a byte array (every
226-
pixel byte becomes `"0,"`).
227-
228-
**The size cap will not save you from a camera** — and it should not be asked to. That
229-
same 640×480 frame is 921 600 bytes on the wire, *under* the default
230-
`max_message_size_bytes` of 1 MiB, so it passes the size gate and lands as 1.8 MB of
231-
JSON. It is `exclude_types` that keeps images out, and removing that list removes the
232-
protection entirely.
222+
| Topic (message) | Publishes at | Bytes per Record |
223+
|---|---|---|
224+
| `/joint_states` (`JointState`, 2 joints) | 1 000 Hz † | 355 |
225+
| `/tf` (`TFMessage`, 1 transform) | 30 Hz | 406 |
226+
| `…/camera_info` (`CameraInfo`) | 5 Hz | 581 |
227+
| `/odom` (`Odometry`) | 30 Hz | 669 |
228+
| `/imu` (`Imu`) | 200 Hz | 767 |
229+
| `/scan` (`LaserScan`, 360 ranges + intensities) | 5 Hz | 7 878 |
230+
| `…/image_raw` (`Image`, 1280×720 `rgb8`) | 5 Hz | **10 981 032** |
231+
232+
† the simulator's `JointStatePublisher` runs every physics step; a real driver is far
233+
slower. It makes no difference to the total, which is the point of the next paragraph.
234+
235+
**The rate cap is what makes the first row affordable, not the topic list.** A topic
236+
publishing faster than `max_rate_hz` contributes `bytes_per_record × 10` per second no
237+
matter how fast it actually runs, so `joint_states` at 1 000 Hz and `imu` at 200 Hz cost
238+
3.6 kB/s and 7.7 kB/s respectively. Only the topics *below* the cap — the 5 Hz sensors —
239+
bill at their real rate, which is why re-enabling one 5 Hz lidar (7.9 kB per Record, 40
240+
kB/s, 3.5 GB/day) nearly triples the total on its own.
241+
242+
The last row is the whole argument for the default type exclusions: one 1280×720 frame
243+
costs as much as ~16 000 odometry Records. JSON does not merely double a byte array —
244+
`10 981 032` bytes for a 2 764 800-byte frame is **four times** the wire size, because
245+
most pixel values print as three digits and a comma.
246+
247+
Two cameras at 5 Hz is 110 MB/s of Records, and **nothing in the pipeline carries that**,
248+
which is why that row says "if anything could carry it": in the run it comes from, the
249+
Shipper refused 475 of the 894 Records offered to it (`dropped … 475 shipper`, 53 %). The
250+
collapse was not confined to the images either — `imu` arrived at 4 Hz instead of 200 and
251+
`odom` at 0.7 Hz instead of 30, because the Bridge spent the window serializing frames.
252+
Collecting a camera raw does not cost you a camera's worth of storage; it costs you the
253+
rest of your collection.
254+
255+
**The size cap will not save you from a camera** — and it should not be asked to. A
256+
640×480 `rgb8` frame is 921 600 bytes on the wire, *under* the default
257+
`max_message_size_bytes` of 1 MiB, so it passes the size gate and lands as megabytes of
258+
JSON. (This world's 1280×720 frames are three times that and the default cap does drop
259+
them whole — which is why the benchmark's camera profile has to lift it to measure
260+
anything at all. Relying on that is a collection policy that silently switches on the day
261+
someone fits a smaller sensor.) It is `exclude_types` that keeps images out, and removing
262+
that list removes the protection entirely.
233263

234264
The tempting fix — lower the size cap until frames stop fitting — is a bad trade, because
235265
**the two limits fail differently**:

progress.txt

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7243,3 +7243,62 @@ eyeballed. `prek run --all-files --skip build-doc` is green.
72437243
and `unknown` otherwise — and routed to the `pgsql` Destination, so the utilisation panels
72447244
populate on the existing demo. The intervention and fault panels stay empty until #362/#365,
72457245
for the same reason a battery panel would.
7246+
7247+
## #326 - Sim-backed benchmark for raw-mode collection volume
7248+
7249+
`raw_topics.md`'s "How much data is this?" numbers came from a hand-written topic emulation:
7250+
plausible rates typed into a generator, not a robot. Now they come off the simulated Waffle.
7251+
`tools/sim/scripts/measure_raw_volume.sh` boots `dc_simulation`'s warehouse world headless,
7252+
drives the robot in a slow circle, runs `dc_bridge` in raw mode against its live topics in
7253+
three configurations (`tools/sim/params/raw_{defaults,sensors,camera}.yaml`) and reports
7254+
per-Tag Records, bytes, bytes/Record, publish rate and a projected GB/day. Local and
7255+
informational, in the spirit of `measure_resources.sh` — deliberately not in `ci.yaml`, which
7256+
would pay minutes of gz-sim boot per PR for a number nobody asserts a threshold on.
7257+
7258+
**The measurement's one real problem: the simulator does not run at real time.** This world
7259+
sits at RTF ~0.04 on 8 CPUs with software rendering, so every wall-clock rate it produces is
7260+
the renderer's number, not the robot's — and worse, `raw.max_rate_hz` limits Records per
7261+
*wall* second, so at that factor the 10 Hz default fires against topics publishing at 250 Hz
7262+
of simulated time (the first trial: 14 859 forwarded, 3 799 dropped rate). Measuring "the
7263+
defaults" that way measures the box. The resolution splits the two halves of the number:
7264+
the profiles collect with the limiter **off**, so every published message becomes a Record;
7265+
the window is counted in *simulated* seconds sampled off `/clock` either side of it; and the
7266+
report multiplies exact bytes-per-Record by `min(published_hz, 10)` — the rate raw mode
7267+
would ship at on a robot where the limiter binds on real time. Bytes per Record are exact
7268+
regardless of how slowly the world runs, and that is what the projection rests on.
7269+
7270+
Reused rather than reimplemented, per the issue: `check_raw()`'s volume accounting moved to
7271+
`tools/e2e/scripts/raw_volume.py` (`Volume`, `summarize_by_tag()`, `project()`), which the
7272+
E2E verifier now calls for the same `stored_bytes`/`bytes_per_record`/`projected_mb_per_day`
7273+
keys it published before (verified identical against a fixture). One wrinkle the sim exposed:
7274+
with `time_key: "date"`, a Record's sink timestamp is the *message's* header stamp — sim time
7275+
under a simulator, and the Bridge's wall clock for headerless messages, so spans across the
7276+
two are meaningless. Hence `arrival_rates=False` for the sim reporter, which takes its window
7277+
from `/clock` instead. Ten fixture tests (`tools/e2e/test/test_raw_volume.py`) pin the
7278+
accounting; a standalone `raw-volume` CI job runs them (stdlib only, no image, no simulator).
7279+
7280+
**What the run says.** Defaults: 24 kB/s, 2.1 GB/day. Plus `scan` and `tf`: 5.9 GB/day — the
7281+
lidar alone is 3.5 GB/day at 7 878 bytes a Record and 5 Hz, because it publishes *below* the
7282+
cap and so bills at its real rate, while `imu` at 200 Hz and `joint_states` at 1 000 Hz are
7283+
decimated to 10. Per-Record: JointState 355, TFMessage 406, CameraInfo 581, Odometry 669,
7284+
Imu 767, LaserScan (360 ranges + intensities) 7 878, and a 1280×720 `rgb8` Image **10 981 032**
7285+
— four times its wire size, not the doubling the old text claimed, because most pixel bytes
7286+
print as three digits and a comma.
7287+
7288+
The camera profile's finding is the one worth having: it is not that images are expensive, it
7289+
is that **the pipeline collapses**. Two cameras at 5 Hz is 110 MB/s of Records; the Shipper
7290+
refused 475 of the 894 offered (53 %), and the rest of the collection went down with them —
7291+
`imu` arrived at 4 Hz instead of 200, `odom` at 0.7 instead of 30, because the Bridge spent
7292+
the window serializing frames. The old doc's "≈1.6 TB/day" reads as a storage bill; the
7293+
measured behaviour is that you lose the robot's other telemetry first. That is now in the
7294+
doc, along with the fact that this world's 2.7 MB frames are *over* the 1 MiB
7295+
`max_message_size_bytes` default (the camera profile has to lift it to measure anything),
7296+
which makes the existing "the size cap will not save you from a camera" argument concrete
7297+
rather than hypothetical.
7298+
7299+
Verified by running it: three profiles, each a fresh container from the workspace image, with
7300+
the Bridge's own drop counters extracted per run as the fidelity check (`dropped 0 rate /
7301+
0 oversize / 0 shipper / 0 undecodable` for `sensors`; the camera profile's 53 % is reported
7302+
as a warning and folded into the doc rather than hidden). `prek run --all-files --skip
7303+
build-doc` is green, `mdbook build` clean, and `tools/sim/.run/` — which `run.sh` has been
7304+
writing to since #279 without ever being ignored — is now in `.gitignore`.

tools/e2e/scripts/raw_volume.py

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
# SPDX-FileCopyrightText: 2022-2026 David Bensoussan
2+
# SPDX-License-Identifier: MPL-2.0
3+
4+
"""Stored-volume accounting for raw-mode (#227) Records.
5+
6+
Both users of it — verify_zero_loss.py's `check_raw()` (one Tag, the E2E harness) and
7+
tools/sim/scripts/measure_raw_volume.sh's reporter (every Tag, the simulated robot) —
8+
have to answer the same question the same way, or the doc's numbers and the harness's
9+
numbers stop being comparable. What a Record *costs* is the bytes the Destination
10+
actually wrote: the JSON line plus the newline the sink terminates it with.
11+
"""
12+
13+
import datetime
14+
import json
15+
16+
17+
class Volume:
18+
"""Bytes, Records and arrival span over one raw Destination's NDJSON output."""
19+
20+
def __init__(self) -> None:
21+
self.records = 0
22+
self.stored_bytes = 0
23+
self.first_seen: datetime.datetime | None = None
24+
self.last_seen: datetime.datetime | None = None
25+
26+
def add_line(self, line: str) -> None:
27+
"""Count one stored line, newline included — what the sink wrote to disk."""
28+
self.records += 1
29+
self.stored_bytes += len(line) + 1
30+
31+
def add_time(self, stored_at) -> None:
32+
"""Extend the arrival span with a Record's sink timestamp, if it parses."""
33+
if not isinstance(stored_at, str):
34+
return
35+
try:
36+
when = datetime.datetime.fromisoformat(stored_at.replace("Z", "+00:00"))
37+
except ValueError:
38+
return
39+
if self.first_seen is None or when < self.first_seen:
40+
self.first_seen = when
41+
if self.last_seen is None or when > self.last_seen:
42+
self.last_seen = when
43+
44+
@property
45+
def span_seconds(self) -> float | None:
46+
"""Wall-clock seconds the Records arrived over, or None if unmeasurable.
47+
48+
Under a second of arrivals is not a rate — dividing by it turns sampling noise
49+
into a throughput claim.
50+
"""
51+
if self.first_seen is None or self.last_seen is None:
52+
return None
53+
span = (self.last_seen - self.first_seen).total_seconds()
54+
return span if span >= 1.0 else None
55+
56+
def summary(self) -> dict:
57+
"""Volume as the report keys both callers publish."""
58+
out = {
59+
"stored_bytes": self.stored_bytes,
60+
"bytes_per_record": self.stored_bytes // self.records if self.records else None,
61+
}
62+
span = self.span_seconds
63+
if span:
64+
out["span_seconds"] = round(span, 1)
65+
out["records_per_second"] = round(self.records / span, 2)
66+
out["bytes_per_second"] = round(self.stored_bytes / span, 1)
67+
out["projected_mb_per_day"] = round(self.stored_bytes / span * 86400 / 1e6, 1)
68+
return out
69+
70+
71+
def project(summary: dict, sim_seconds: float, max_rate_hz: float) -> dict:
72+
"""What one Tag's measured volume is worth on a robot running at real time (#326).
73+
74+
A simulator that runs at a fraction of real time makes wall-clock throughput
75+
meaningless — every rate it produces is scaled by the real-time factor. Two things
76+
survive that scaling: the bytes a Record costs, and the rate the robot publishes at,
77+
which is measured in *simulated* seconds. Multiply them, after applying the rate
78+
limiter that would bind on a real robot, and the projection is the robot's, not the
79+
simulator's.
80+
81+
Args:
82+
summary: one Tag's entry from `summarize_by_tag()`.
83+
sim_seconds: simulated seconds the measurement window covered.
84+
max_rate_hz: the profile's `raw.max_rate_hz` (0 = no limiter).
85+
"""
86+
per_record = summary.get("bytes_per_record")
87+
if not per_record or sim_seconds <= 0:
88+
return {}
89+
published_hz = summary["records"] / sim_seconds
90+
shipped_hz = min(published_hz, max_rate_hz) if max_rate_hz else published_hz
91+
bytes_per_second = per_record * shipped_hz
92+
return {
93+
"published_hz": round(published_hz, 2),
94+
"shipped_hz": round(shipped_hz, 2),
95+
"projected_bytes_per_second": round(bytes_per_second, 1),
96+
"projected_gb_per_day": round(bytes_per_second * 86400 / 1e9, 3),
97+
}
98+
99+
100+
def summarize_by_tag(path: str, arrival_rates: bool = True) -> dict:
101+
"""Per-Tag and total volume over a raw Destination's NDJSON file.
102+
103+
A line that isn't JSON still cost its bytes, so it counts toward the total under the
104+
`(unparsable)` Tag rather than vanishing from the accounting.
105+
106+
Args:
107+
path: the Destination's newline-delimited JSON.
108+
arrival_rates: keep the timestamp-derived rates. Off for a simulated robot: the
109+
sink timestamp is the Record's `date`, which for a message carrying a
110+
`std_msgs/msg/Header` is the *message's* stamp — simulated time under a
111+
simulator, and mixed with the Bridge's wall clock for messages without a
112+
header. Spans across the two are meaningless; rates there come from the
113+
simulated window instead (`project()`).
114+
"""
115+
total = Volume()
116+
per_tag: dict[str, Volume] = {}
117+
118+
with open(path) as f:
119+
for line in f:
120+
line = line.strip()
121+
if not line:
122+
continue
123+
try:
124+
event = json.loads(line)
125+
tag = event.get("tag") or "(untagged)"
126+
stored_at = event.get("timestamp")
127+
except json.JSONDecodeError:
128+
tag, stored_at = "(unparsable)", None
129+
total.add_line(line)
130+
if arrival_rates:
131+
total.add_time(stored_at)
132+
volume = per_tag.setdefault(tag, Volume())
133+
volume.add_line(line)
134+
if arrival_rates:
135+
volume.add_time(stored_at)
136+
137+
return {
138+
"tags": {
139+
tag: {"records": v.records, **v.summary()}
140+
for tag, v in sorted(per_tag.items(), key=lambda kv: -kv[1].stored_bytes)
141+
},
142+
"total": {"records": total.records, **total.summary()},
143+
}

0 commit comments

Comments
 (0)