baglint 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- baglint-0.1.0/.gitignore +6 -0
- baglint-0.1.0/LICENSE +22 -0
- baglint-0.1.0/Makefile +18 -0
- baglint-0.1.0/PKG-INFO +198 -0
- baglint-0.1.0/README.md +175 -0
- baglint-0.1.0/examples/demo_spec.yaml +14 -0
- baglint-0.1.0/examples/make_demo_bag.py +36 -0
- baglint-0.1.0/examples/spec.yaml +19 -0
- baglint-0.1.0/pyproject.toml +42 -0
- baglint-0.1.0/src/baglint/__init__.py +7 -0
- baglint-0.1.0/src/baglint/checks/__init__.py +18 -0
- baglint-0.1.0/src/baglint/checks/base.py +85 -0
- baglint-0.1.0/src/baglint/checks/gap.py +80 -0
- baglint-0.1.0/src/baglint/checks/presence.py +42 -0
- baglint-0.1.0/src/baglint/checks/rate.py +63 -0
- baglint-0.1.0/src/baglint/checks/stamp.py +142 -0
- baglint-0.1.0/src/baglint/cli.py +69 -0
- baglint-0.1.0/src/baglint/findings.py +63 -0
- baglint-0.1.0/src/baglint/init.py +72 -0
- baglint-0.1.0/src/baglint/reader.py +129 -0
- baglint-0.1.0/src/baglint/report.py +88 -0
- baglint-0.1.0/src/baglint/runner.py +65 -0
- baglint-0.1.0/src/baglint/spec.py +89 -0
- baglint-0.1.0/tests/conftest.py +4 -0
- baglint-0.1.0/tests/fixtures.py +136 -0
- baglint-0.1.0/tests/test_cli.py +77 -0
- baglint-0.1.0/tests/test_edge_cases.py +55 -0
- baglint-0.1.0/tests/test_gap.py +73 -0
- baglint-0.1.0/tests/test_init.py +83 -0
- baglint-0.1.0/tests/test_rate_and_presence.py +56 -0
- baglint-0.1.0/tests/test_spec.py +30 -0
- baglint-0.1.0/tests/test_stamp.py +77 -0
baglint-0.1.0/.gitignore
ADDED
baglint-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Puja
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
22
|
+
|
baglint-0.1.0/Makefile
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# ROS installs put /opt/ros/*/site-packages on PYTHONPATH, which leaks into the
|
|
2
|
+
# venv and drags ROS's pytest plugins into our test run. baglint is deliberately
|
|
3
|
+
# ROS-free, so every target runs with PYTHONPATH cleared.
|
|
4
|
+
PY := .venv/bin/python
|
|
5
|
+
RUN := env -u PYTHONPATH
|
|
6
|
+
|
|
7
|
+
.PHONY: install test lint clean
|
|
8
|
+
|
|
9
|
+
install:
|
|
10
|
+
python3 -m venv .venv
|
|
11
|
+
$(RUN) $(PY) -m pip install -q --upgrade pip
|
|
12
|
+
$(RUN) $(PY) -m pip install -q -e ".[dev]"
|
|
13
|
+
|
|
14
|
+
test:
|
|
15
|
+
$(RUN) $(PY) -m pytest -q
|
|
16
|
+
|
|
17
|
+
clean:
|
|
18
|
+
rm -rf .venv .pytest_cache **/__pycache__ *.egg-info
|
baglint-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: baglint
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Validates the contents of MCAP recordings against a declarative specification
|
|
5
|
+
Project-URL: Homepage, https://github.com/catplotlib/baglint
|
|
6
|
+
Project-URL: Issues, https://github.com/catplotlib/baglint/issues
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Keywords: linter,mcap,robotics,ros2,rosbag,validation
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: Intended Audience :: Science/Research
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Topic :: Scientific/Engineering
|
|
15
|
+
Classifier: Topic :: Software Development :: Quality Assurance
|
|
16
|
+
Requires-Python: >=3.10
|
|
17
|
+
Requires-Dist: mcap-ros2-support>=0.5
|
|
18
|
+
Requires-Dist: mcap>=1.2
|
|
19
|
+
Requires-Dist: pyyaml>=6.0
|
|
20
|
+
Provides-Extra: dev
|
|
21
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
|
|
24
|
+
# baglint
|
|
25
|
+
|
|
26
|
+
Validates the contents of MCAP recordings against a declarative specification.
|
|
27
|
+
Checks for missing topics, recording gaps and rate violations, and returns a
|
|
28
|
+
non-zero exit status when a recording does not satisfy the specification.
|
|
29
|
+
|
|
30
|
+
Validation of the MCAP container itself, such as chunk CRCs and index
|
|
31
|
+
integrity, is out of scope. Use `mcap doctor` for that.
|
|
32
|
+
|
|
33
|
+
## Requirements
|
|
34
|
+
|
|
35
|
+
* Python 3.10 or later
|
|
36
|
+
|
|
37
|
+
## Installation
|
|
38
|
+
|
|
39
|
+
```console
|
|
40
|
+
$ python3 -m venv .venv
|
|
41
|
+
$ .venv/bin/pip install -e .
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
When ROS 2 is sourced, `/opt/ros/$ROS_DISTRO/lib/python3.*/site-packages` is on
|
|
45
|
+
`PYTHONPATH` and is inherited by the virtual environment, which causes ROS
|
|
46
|
+
pytest plugins to load during test runs. Clear it when invoking the tool:
|
|
47
|
+
|
|
48
|
+
```console
|
|
49
|
+
$ env -u PYTHONPATH .venv/bin/baglint --help
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## Usage
|
|
53
|
+
|
|
54
|
+
```console
|
|
55
|
+
$ baglint BAG [-s SPEC] [-f {text,json}] [--strict]
|
|
56
|
+
$ baglint BAG --init [--margin FRACTION]
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
| Option | Description |
|
|
60
|
+
| --- | --- |
|
|
61
|
+
| `-s`, `--spec` | Specification file to validate against |
|
|
62
|
+
| `-f`, `--format` | Output format, `text` (default) or `json` |
|
|
63
|
+
| `--strict` | Exit non-zero on `WARN` findings as well as `FAIL` |
|
|
64
|
+
| `--init` | Print a specification generated from the recording instead of validating it |
|
|
65
|
+
| `--margin` | With `--init`, the fraction below the observed rate at which to set `min_rate`. Default `0.1` |
|
|
66
|
+
| `--version` | Print version and exit |
|
|
67
|
+
|
|
68
|
+
Without `-s`, no checks run and nothing is validated.
|
|
69
|
+
|
|
70
|
+
## Generating a specification
|
|
71
|
+
|
|
72
|
+
`--init` writes a specification describing a recording, which is the practical
|
|
73
|
+
way to produce a first one:
|
|
74
|
+
|
|
75
|
+
```console
|
|
76
|
+
$ baglint good_run.mcap --init > spec.yaml
|
|
77
|
+
$ baglint experiment_042.mcap --spec spec.yaml
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
`min_rate` is set below each topic's observed mean rate by `--margin`, and
|
|
81
|
+
`max_gap_ms` to twice the worst interval observed. Topics with fewer than ten
|
|
82
|
+
messages are generated as presence-only, since a mean rate over so few samples
|
|
83
|
+
describes the recording length rather than the publisher.
|
|
84
|
+
|
|
85
|
+
The bounds describe the recording they came from, defects included. Generate
|
|
86
|
+
from a run that is known to be good, and treat the result as a starting point.
|
|
87
|
+
|
|
88
|
+
## Specification
|
|
89
|
+
|
|
90
|
+
Topic keys accept glob patterns. The first matching entry applies, so list
|
|
91
|
+
specific topics before wildcards.
|
|
92
|
+
|
|
93
|
+
```yaml
|
|
94
|
+
topics:
|
|
95
|
+
/joint_states:
|
|
96
|
+
min_rate: 490
|
|
97
|
+
max_gap_ms: 10
|
|
98
|
+
|
|
99
|
+
/camera/*:
|
|
100
|
+
min_rate: 25
|
|
101
|
+
|
|
102
|
+
/diagnostics:
|
|
103
|
+
required: false
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
| Key | Type | Description |
|
|
107
|
+
| --- | --- | --- |
|
|
108
|
+
| `min_rate` | float | Minimum mean publication rate in Hz, measured over the topic's own span |
|
|
109
|
+
| `max_gap_ms` | float | Maximum permitted interval between consecutive messages |
|
|
110
|
+
| `required` | bool | Whether a topic named literally must be present. Default `true` |
|
|
111
|
+
| `check_stamps` | bool | Validate `header.stamp` ordering. Default `false`, as it deserializes payloads |
|
|
112
|
+
|
|
113
|
+
A `transforms.required` list of `[parent, child]` frame pairs is parsed and
|
|
114
|
+
validated, but no check consumes it yet.
|
|
115
|
+
|
|
116
|
+
## Output
|
|
117
|
+
|
|
118
|
+
```console
|
|
119
|
+
$ baglint experiment_042.mcap --spec examples/demo_spec.yaml
|
|
120
|
+
experiment_042.mcap
|
|
121
|
+
3 topics · 680,884 messages · 18m32s
|
|
122
|
+
|
|
123
|
+
FAIL /camera/image
|
|
124
|
+
rate 12.4 Hz below minimum 25 Hz [log_time]
|
|
125
|
+
|
|
126
|
+
FAIL /joint_states
|
|
127
|
+
3 missing interval(s) >10 ms (worst 122.0 ms at 904.00 s) [log_time]
|
|
128
|
+
|
|
129
|
+
FAIL /tf
|
|
130
|
+
required by spec but no messages in bag
|
|
131
|
+
|
|
132
|
+
3 findings: 3 FAIL
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
To regenerate the recording used above:
|
|
136
|
+
|
|
137
|
+
```console
|
|
138
|
+
$ python examples/make_demo_bag.py experiment_042.mcap
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
`--format json` emits the same findings for machine consumption. Each carries a
|
|
142
|
+
stable `code`, intended for filtering and baselining in CI:
|
|
143
|
+
|
|
144
|
+
| Code | Level | Meaning |
|
|
145
|
+
| --- | --- | --- |
|
|
146
|
+
| `gap` | FAIL | Consecutive messages exceeded `max_gap_ms` |
|
|
147
|
+
| `rate_below_min` | FAIL | Mean rate fell below `min_rate` |
|
|
148
|
+
| `rate_unmeasurable` | FAIL | Fewer than two messages, so no rate can be computed |
|
|
149
|
+
| `missing_topic` | FAIL | A required topic carried no messages |
|
|
150
|
+
| `stamp_backwards` | FAIL | A `header.stamp` preceded the stamp before it |
|
|
151
|
+
| `stamp_duplicate` | WARN | A message repeated the previous `header.stamp` |
|
|
152
|
+
| `stamp_unset` | WARN | A message carried a zero `header.stamp` |
|
|
153
|
+
| `stamp_unavailable` | WARN | `check_stamps` was set on a message type without a header |
|
|
154
|
+
|
|
155
|
+
## Message timestamps
|
|
156
|
+
|
|
157
|
+
Each message carries two timestamps, and findings state which one was used:
|
|
158
|
+
|
|
159
|
+
| Timestamp | Set by | A defect indicates |
|
|
160
|
+
| --- | --- | --- |
|
|
161
|
+
| `log_time` | the recorder, on write | the recorder stalled: disk I/O, CPU starvation, a terminated node |
|
|
162
|
+
| `header.stamp` | the publisher, at sample time | the sensor or its driver misbehaved |
|
|
163
|
+
|
|
164
|
+
Gap and rate checks read `log_time`. Stamp checks read `header.stamp` and are
|
|
165
|
+
enabled per topic with `check_stamps`, which is off by default because it
|
|
166
|
+
requires deserializing every message on that topic:
|
|
167
|
+
|
|
168
|
+
```yaml
|
|
169
|
+
topics:
|
|
170
|
+
/imu:
|
|
171
|
+
check_stamps: true
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
A stamp that moves backwards is reported as a failure. Downstream consumers
|
|
175
|
+
such as tf2, `message_filters` and the SLAM backends assume a non-decreasing
|
|
176
|
+
stamp sequence per topic, and do not report the violation themselves. Duplicate and zero stamps
|
|
177
|
+
are reported as warnings.
|
|
178
|
+
|
|
179
|
+
A zero stamp is treated as never populated rather than as a timestamp, so it
|
|
180
|
+
does not become the baseline for the messages that follow it.
|
|
181
|
+
|
|
182
|
+
## Exit codes
|
|
183
|
+
|
|
184
|
+
| Code | Condition |
|
|
185
|
+
| --- | --- |
|
|
186
|
+
| 0 | No `FAIL` findings. With `--strict`, no `WARN` findings either |
|
|
187
|
+
| 1 | At least one finding at or above the failing level |
|
|
188
|
+
| 2 | Invalid arguments, unreadable recording, or malformed specification |
|
|
189
|
+
|
|
190
|
+
## Development
|
|
191
|
+
|
|
192
|
+
```console
|
|
193
|
+
$ make install
|
|
194
|
+
$ make test
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
Tests construct MCAP recordings with injected defects at known offsets. See
|
|
198
|
+
`tests/fixtures.py`.
|
baglint-0.1.0/README.md
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
# baglint
|
|
2
|
+
|
|
3
|
+
Validates the contents of MCAP recordings against a declarative specification.
|
|
4
|
+
Checks for missing topics, recording gaps and rate violations, and returns a
|
|
5
|
+
non-zero exit status when a recording does not satisfy the specification.
|
|
6
|
+
|
|
7
|
+
Validation of the MCAP container itself, such as chunk CRCs and index
|
|
8
|
+
integrity, is out of scope. Use `mcap doctor` for that.
|
|
9
|
+
|
|
10
|
+
## Requirements
|
|
11
|
+
|
|
12
|
+
* Python 3.10 or later
|
|
13
|
+
|
|
14
|
+
## Installation
|
|
15
|
+
|
|
16
|
+
```console
|
|
17
|
+
$ python3 -m venv .venv
|
|
18
|
+
$ .venv/bin/pip install -e .
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
When ROS 2 is sourced, `/opt/ros/$ROS_DISTRO/lib/python3.*/site-packages` is on
|
|
22
|
+
`PYTHONPATH` and is inherited by the virtual environment, which causes ROS
|
|
23
|
+
pytest plugins to load during test runs. Clear it when invoking the tool:
|
|
24
|
+
|
|
25
|
+
```console
|
|
26
|
+
$ env -u PYTHONPATH .venv/bin/baglint --help
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## Usage
|
|
30
|
+
|
|
31
|
+
```console
|
|
32
|
+
$ baglint BAG [-s SPEC] [-f {text,json}] [--strict]
|
|
33
|
+
$ baglint BAG --init [--margin FRACTION]
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
| Option | Description |
|
|
37
|
+
| --- | --- |
|
|
38
|
+
| `-s`, `--spec` | Specification file to validate against |
|
|
39
|
+
| `-f`, `--format` | Output format, `text` (default) or `json` |
|
|
40
|
+
| `--strict` | Exit non-zero on `WARN` findings as well as `FAIL` |
|
|
41
|
+
| `--init` | Print a specification generated from the recording instead of validating it |
|
|
42
|
+
| `--margin` | With `--init`, the fraction below the observed rate at which to set `min_rate`. Default `0.1` |
|
|
43
|
+
| `--version` | Print version and exit |
|
|
44
|
+
|
|
45
|
+
Without `-s`, no checks run and nothing is validated.
|
|
46
|
+
|
|
47
|
+
## Generating a specification
|
|
48
|
+
|
|
49
|
+
`--init` writes a specification describing a recording, which is the practical
|
|
50
|
+
way to produce a first one:
|
|
51
|
+
|
|
52
|
+
```console
|
|
53
|
+
$ baglint good_run.mcap --init > spec.yaml
|
|
54
|
+
$ baglint experiment_042.mcap --spec spec.yaml
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
`min_rate` is set below each topic's observed mean rate by `--margin`, and
|
|
58
|
+
`max_gap_ms` to twice the worst interval observed. Topics with fewer than ten
|
|
59
|
+
messages are generated as presence-only, since a mean rate over so few samples
|
|
60
|
+
describes the recording length rather than the publisher.
|
|
61
|
+
|
|
62
|
+
The bounds describe the recording they came from, defects included. Generate
|
|
63
|
+
from a run that is known to be good, and treat the result as a starting point.
|
|
64
|
+
|
|
65
|
+
## Specification
|
|
66
|
+
|
|
67
|
+
Topic keys accept glob patterns. The first matching entry applies, so list
|
|
68
|
+
specific topics before wildcards.
|
|
69
|
+
|
|
70
|
+
```yaml
|
|
71
|
+
topics:
|
|
72
|
+
/joint_states:
|
|
73
|
+
min_rate: 490
|
|
74
|
+
max_gap_ms: 10
|
|
75
|
+
|
|
76
|
+
/camera/*:
|
|
77
|
+
min_rate: 25
|
|
78
|
+
|
|
79
|
+
/diagnostics:
|
|
80
|
+
required: false
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
| Key | Type | Description |
|
|
84
|
+
| --- | --- | --- |
|
|
85
|
+
| `min_rate` | float | Minimum mean publication rate in Hz, measured over the topic's own span |
|
|
86
|
+
| `max_gap_ms` | float | Maximum permitted interval between consecutive messages |
|
|
87
|
+
| `required` | bool | Whether a topic named literally must be present. Default `true` |
|
|
88
|
+
| `check_stamps` | bool | Validate `header.stamp` ordering. Default `false`, as it deserializes payloads |
|
|
89
|
+
|
|
90
|
+
A `transforms.required` list of `[parent, child]` frame pairs is parsed and
|
|
91
|
+
validated, but no check consumes it yet.
|
|
92
|
+
|
|
93
|
+
## Output
|
|
94
|
+
|
|
95
|
+
```console
|
|
96
|
+
$ baglint experiment_042.mcap --spec examples/demo_spec.yaml
|
|
97
|
+
experiment_042.mcap
|
|
98
|
+
3 topics · 680,884 messages · 18m32s
|
|
99
|
+
|
|
100
|
+
FAIL /camera/image
|
|
101
|
+
rate 12.4 Hz below minimum 25 Hz [log_time]
|
|
102
|
+
|
|
103
|
+
FAIL /joint_states
|
|
104
|
+
3 missing interval(s) >10 ms (worst 122.0 ms at 904.00 s) [log_time]
|
|
105
|
+
|
|
106
|
+
FAIL /tf
|
|
107
|
+
required by spec but no messages in bag
|
|
108
|
+
|
|
109
|
+
3 findings: 3 FAIL
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
To regenerate the recording used above:
|
|
113
|
+
|
|
114
|
+
```console
|
|
115
|
+
$ python examples/make_demo_bag.py experiment_042.mcap
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
`--format json` emits the same findings for machine consumption. Each carries a
|
|
119
|
+
stable `code`, intended for filtering and baselining in CI:
|
|
120
|
+
|
|
121
|
+
| Code | Level | Meaning |
|
|
122
|
+
| --- | --- | --- |
|
|
123
|
+
| `gap` | FAIL | Consecutive messages exceeded `max_gap_ms` |
|
|
124
|
+
| `rate_below_min` | FAIL | Mean rate fell below `min_rate` |
|
|
125
|
+
| `rate_unmeasurable` | FAIL | Fewer than two messages, so no rate can be computed |
|
|
126
|
+
| `missing_topic` | FAIL | A required topic carried no messages |
|
|
127
|
+
| `stamp_backwards` | FAIL | A `header.stamp` preceded the stamp before it |
|
|
128
|
+
| `stamp_duplicate` | WARN | A message repeated the previous `header.stamp` |
|
|
129
|
+
| `stamp_unset` | WARN | A message carried a zero `header.stamp` |
|
|
130
|
+
| `stamp_unavailable` | WARN | `check_stamps` was set on a message type without a header |
|
|
131
|
+
|
|
132
|
+
## Message timestamps
|
|
133
|
+
|
|
134
|
+
Each message carries two timestamps, and findings state which one was used:
|
|
135
|
+
|
|
136
|
+
| Timestamp | Set by | A defect indicates |
|
|
137
|
+
| --- | --- | --- |
|
|
138
|
+
| `log_time` | the recorder, on write | the recorder stalled: disk I/O, CPU starvation, a terminated node |
|
|
139
|
+
| `header.stamp` | the publisher, at sample time | the sensor or its driver misbehaved |
|
|
140
|
+
|
|
141
|
+
Gap and rate checks read `log_time`. Stamp checks read `header.stamp` and are
|
|
142
|
+
enabled per topic with `check_stamps`, which is off by default because it
|
|
143
|
+
requires deserializing every message on that topic:
|
|
144
|
+
|
|
145
|
+
```yaml
|
|
146
|
+
topics:
|
|
147
|
+
/imu:
|
|
148
|
+
check_stamps: true
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
A stamp that moves backwards is reported as a failure. Downstream consumers
|
|
152
|
+
such as tf2, `message_filters` and the SLAM backends assume a non-decreasing
|
|
153
|
+
stamp sequence per topic, and do not report the violation themselves. Duplicate and zero stamps
|
|
154
|
+
are reported as warnings.
|
|
155
|
+
|
|
156
|
+
A zero stamp is treated as never populated rather than as a timestamp, so it
|
|
157
|
+
does not become the baseline for the messages that follow it.
|
|
158
|
+
|
|
159
|
+
## Exit codes
|
|
160
|
+
|
|
161
|
+
| Code | Condition |
|
|
162
|
+
| --- | --- |
|
|
163
|
+
| 0 | No `FAIL` findings. With `--strict`, no `WARN` findings either |
|
|
164
|
+
| 1 | At least one finding at or above the failing level |
|
|
165
|
+
| 2 | Invalid arguments, unreadable recording, or malformed specification |
|
|
166
|
+
|
|
167
|
+
## Development
|
|
168
|
+
|
|
169
|
+
```console
|
|
170
|
+
$ make install
|
|
171
|
+
$ make test
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
Tests construct MCAP recordings with injected defects at known offsets. See
|
|
175
|
+
`tests/fixtures.py`.
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# Spec used for the README's example output, against examples/make_demo_bag.py.
|
|
2
|
+
topics:
|
|
3
|
+
/joint_states:
|
|
4
|
+
min_rate: 490 # tolerance below the nominal 500 Hz loop rate
|
|
5
|
+
max_gap_ms: 10
|
|
6
|
+
|
|
7
|
+
/camera/image:
|
|
8
|
+
min_rate: 25
|
|
9
|
+
|
|
10
|
+
/imu:
|
|
11
|
+
min_rate: 100
|
|
12
|
+
|
|
13
|
+
/tf:
|
|
14
|
+
min_rate: 10
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Generate the demo bag used for the README's example output.
|
|
3
|
+
|
|
4
|
+
The synthetic-bag helper lives in tests/ rather than in the package: it is a
|
|
5
|
+
test utility, not shipped API, so this script puts it on the path explicitly.
|
|
6
|
+
|
|
7
|
+
python examples/make_demo_bag.py /tmp/experiment_042.mcap
|
|
8
|
+
baglint /tmp/experiment_042.mcap --spec examples/demo_spec.yaml
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import sys
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "tests"))
|
|
15
|
+
|
|
16
|
+
from fixtures import SynthBag # noqa: E402
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def build(path: Path) -> Path:
|
|
20
|
+
bag = SynthBag(path, duration=1112.0)
|
|
21
|
+
|
|
22
|
+
# A 500 Hz control loop that stalls three times.
|
|
23
|
+
bag.topic("/joint_states", rate=500).gap(at=180.0, ms=52).gap(at=312.4, ms=38).gap(at=904.0, ms=120)
|
|
24
|
+
|
|
25
|
+
# A camera that never reaches its configured frame rate.
|
|
26
|
+
bag.topic("/camera/image", rate=12.4)
|
|
27
|
+
|
|
28
|
+
bag.topic("/imu", rate=100)
|
|
29
|
+
|
|
30
|
+
# /tf is absent entirely, which the spec requires.
|
|
31
|
+
return bag.write()
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
if __name__ == "__main__":
|
|
35
|
+
out = Path(sys.argv[1] if len(sys.argv) > 1 else "experiment_042.mcap")
|
|
36
|
+
print(f"wrote {build(out)}")
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# Example baglint spec. Topic keys may be glob patterns; the first entry that
|
|
2
|
+
# matches a topic wins, so list specific topics before wildcards.
|
|
3
|
+
|
|
4
|
+
topics:
|
|
5
|
+
/joint_states:
|
|
6
|
+
min_rate: 500
|
|
7
|
+
max_gap_ms: 10
|
|
8
|
+
|
|
9
|
+
/camera/image:
|
|
10
|
+
min_rate: 25
|
|
11
|
+
max_gap_ms: 100
|
|
12
|
+
|
|
13
|
+
/imu:
|
|
14
|
+
min_rate: 100
|
|
15
|
+
|
|
16
|
+
# Not yet implemented -- parsed and validated, but no check consumes it in v0.1.
|
|
17
|
+
transforms:
|
|
18
|
+
required:
|
|
19
|
+
- [camera_link, base_link]
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "baglint"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Validates the contents of MCAP recordings against a declarative specification"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
license-files = ["LICENSE"]
|
|
13
|
+
keywords = ["mcap", "rosbag", "ros2", "robotics", "validation", "linter"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 3 - Alpha",
|
|
16
|
+
"Intended Audience :: Developers",
|
|
17
|
+
"Intended Audience :: Science/Research",
|
|
18
|
+
"Programming Language :: Python :: 3",
|
|
19
|
+
"Topic :: Scientific/Engineering",
|
|
20
|
+
"Topic :: Software Development :: Quality Assurance",
|
|
21
|
+
]
|
|
22
|
+
dependencies = [
|
|
23
|
+
"mcap>=1.2",
|
|
24
|
+
"mcap-ros2-support>=0.5",
|
|
25
|
+
"pyyaml>=6.0",
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
[project.urls]
|
|
29
|
+
Homepage = "https://github.com/catplotlib/baglint"
|
|
30
|
+
Issues = "https://github.com/catplotlib/baglint/issues"
|
|
31
|
+
|
|
32
|
+
[project.optional-dependencies]
|
|
33
|
+
dev = ["pytest>=8.0"]
|
|
34
|
+
|
|
35
|
+
[project.scripts]
|
|
36
|
+
baglint = "baglint.cli:main"
|
|
37
|
+
|
|
38
|
+
[tool.hatch.build.targets.wheel]
|
|
39
|
+
packages = ["src/baglint"]
|
|
40
|
+
|
|
41
|
+
[tool.pytest.ini_options]
|
|
42
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
from baglint.checks.base import Check, RunContext, TopicStat
|
|
2
|
+
from baglint.checks.gap import GapCheck
|
|
3
|
+
from baglint.checks.presence import PresenceCheck
|
|
4
|
+
from baglint.checks.rate import RateCheck
|
|
5
|
+
from baglint.checks.stamp import StampCheck
|
|
6
|
+
|
|
7
|
+
ALL_CHECKS = [PresenceCheck, GapCheck, RateCheck, StampCheck]
|
|
8
|
+
|
|
9
|
+
__all__ = [
|
|
10
|
+
"Check",
|
|
11
|
+
"RunContext",
|
|
12
|
+
"TopicStat",
|
|
13
|
+
"GapCheck",
|
|
14
|
+
"PresenceCheck",
|
|
15
|
+
"RateCheck",
|
|
16
|
+
"StampCheck",
|
|
17
|
+
"ALL_CHECKS",
|
|
18
|
+
]
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from typing import Iterable, Protocol
|
|
5
|
+
|
|
6
|
+
from baglint.findings import Finding
|
|
7
|
+
from baglint.reader import Message
|
|
8
|
+
from baglint.spec import Spec
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass
|
|
12
|
+
class TopicStat:
|
|
13
|
+
"""Per-topic timing, accumulated by the runner for every topic in the bag."""
|
|
14
|
+
|
|
15
|
+
topic: str
|
|
16
|
+
count: int = 0
|
|
17
|
+
first_ns: int | None = None
|
|
18
|
+
last_ns: int | None = None
|
|
19
|
+
max_interval_ns: int = 0
|
|
20
|
+
|
|
21
|
+
def observe(self, log_time_ns: int) -> None:
|
|
22
|
+
if self.first_ns is None:
|
|
23
|
+
self.first_ns = log_time_ns
|
|
24
|
+
else:
|
|
25
|
+
self.max_interval_ns = max(self.max_interval_ns, log_time_ns - self.last_ns)
|
|
26
|
+
self.last_ns = log_time_ns
|
|
27
|
+
self.count += 1
|
|
28
|
+
|
|
29
|
+
@property
|
|
30
|
+
def max_interval_ms(self) -> float:
|
|
31
|
+
return self.max_interval_ns / 1e6
|
|
32
|
+
|
|
33
|
+
@property
|
|
34
|
+
def span_s(self) -> float:
|
|
35
|
+
if self.first_ns is None or self.last_ns is None:
|
|
36
|
+
return 0.0
|
|
37
|
+
return (self.last_ns - self.first_ns) / 1e9
|
|
38
|
+
|
|
39
|
+
@property
|
|
40
|
+
def rate_hz(self) -> float | None:
|
|
41
|
+
"""Mean rate over the topic's own span.
|
|
42
|
+
|
|
43
|
+
Uses count-1 intervals rather than count messages: for N evenly spaced
|
|
44
|
+
messages there are N-1 gaps, and dividing by N biases the rate low on
|
|
45
|
+
short recordings.
|
|
46
|
+
"""
|
|
47
|
+
if self.count < 2 or self.span_s <= 0:
|
|
48
|
+
return None
|
|
49
|
+
return (self.count - 1) / self.span_s
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass
|
|
53
|
+
class RunContext:
|
|
54
|
+
spec: Spec
|
|
55
|
+
stats: dict[str, TopicStat] = field(default_factory=dict)
|
|
56
|
+
start_ns: int = 0
|
|
57
|
+
end_ns: int = 0
|
|
58
|
+
|
|
59
|
+
@property
|
|
60
|
+
def duration_s(self) -> float:
|
|
61
|
+
return max(0.0, (self.end_ns - self.start_ns) / 1e9)
|
|
62
|
+
|
|
63
|
+
def rel_s(self, ns: int) -> float:
|
|
64
|
+
"""Seconds since the first message in the bag."""
|
|
65
|
+
return (ns - self.start_ns) / 1e9
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class Check(Protocol):
|
|
69
|
+
"""A streaming accumulator.
|
|
70
|
+
|
|
71
|
+
Checks see every message once, in log_time order, then report at the end.
|
|
72
|
+
Payload deserialization is opt-in per check, via decode_topics().
|
|
73
|
+
"""
|
|
74
|
+
|
|
75
|
+
def decode_topics(self, topics: Iterable[str]) -> set[str]:
|
|
76
|
+
"""Topics whose payloads this check needs deserialized.
|
|
77
|
+
|
|
78
|
+
Resolved against the bag's actual channel list so spec globs can be
|
|
79
|
+
expanded. Return an empty set unless message fields are actually read.
|
|
80
|
+
"""
|
|
81
|
+
...
|
|
82
|
+
|
|
83
|
+
def on_message(self, msg: Message) -> None: ...
|
|
84
|
+
|
|
85
|
+
def finalize(self, ctx: RunContext) -> list[Finding]: ...
|