Metadata-Version: 2.5
Name: baglint
Version: 0.2.0
Summary: Validates the contents of MCAP recordings against a declarative specification
Project-URL: Homepage, https://github.com/catplotlib/baglint
Project-URL: Issues, https://github.com/catplotlib/baglint/issues
License-Expression: MIT
License-File: LICENSE
Keywords: linter,mcap,robotics,ros2,rosbag,validation
Classifier: Development Status :: 3 - Alpha
Classifier: Intended Audience :: Developers
Classifier: Intended Audience :: Science/Research
Classifier: Programming Language :: Python :: 3
Classifier: Topic :: Scientific/Engineering
Classifier: Topic :: Software Development :: Quality Assurance
Requires-Python: >=3.10
Requires-Dist: mcap-ros2-support>=0.5
Requires-Dist: mcap>=1.2
Requires-Dist: pyyaml>=6.0
Provides-Extra: dev
Requires-Dist: pytest>=8.0; extra == 'dev'
Description-Content-Type: text/markdown

# baglint

Validates the contents of MCAP recordings against a declarative specification.
Checks for missing topics, recording gaps and rate violations, and returns a
non-zero exit status when a recording does not satisfy the specification.

Validation of the MCAP container itself, such as chunk CRCs and index
integrity, is out of scope. Use `mcap doctor` for that.

## Requirements

* Python 3.10 or later

## Installation

```console
$ python3 -m venv .venv
$ .venv/bin/pip install -e .
```

When ROS 2 is sourced, `/opt/ros/$ROS_DISTRO/lib/python3.*/site-packages` is on
`PYTHONPATH` and is inherited by the virtual environment, which causes ROS
pytest plugins to load during test runs. Clear it when invoking the tool:

```console
$ env -u PYTHONPATH .venv/bin/baglint --help
```

## Usage

```console
$ baglint BAG [-s SPEC] [-f {text,json}] [--strict]
$ baglint BAG --init [--margin FRACTION]
```

| Option | Description |
| --- | --- |
| `-s`, `--spec` | Specification file to validate against |
| `-f`, `--format` | Output format, `text` (default), `json` or `html` |
| `--strict` | Exit non-zero on `WARN` findings as well as `FAIL` |
| `--init` | Print a specification generated from the recording instead of validating it |
| `--margin` | With `--init`, the fraction below the observed rate at which to set `min_rate`. Default `0.1` |
| `--version` | Print version and exit |

Without `-s`, no checks run and nothing is validated.

## Generating a specification

`--init` writes a specification describing a recording, which is the practical
way to produce a first one:

```console
$ baglint good_run.mcap --init > spec.yaml
$ baglint run_028.mcap --spec spec.yaml
```

`min_rate` is set below each topic's observed mean rate by `--margin`, and
`max_gap_ms` to twice the worst interval observed. Topics with fewer than ten
messages are generated as presence-only, since a mean rate over so few samples
describes the recording length rather than the publisher.

The bounds describe the recording they came from, defects included. Generate
from a run that is known to be good, and treat the result as a starting point.

## Specification

Topic keys accept glob patterns. The first matching entry applies, so list
specific topics before wildcards.

```yaml
topics:
  /joint_states:
    min_rate: 490
    max_gap_ms: 10

  /camera/*:
    min_rate: 25

  /diagnostics:
    required: false
```

| Key | Type | Description |
| --- | --- | --- |
| `min_rate` | float | Minimum mean publication rate in Hz, measured over the topic's own span |
| `max_gap_ms` | float | Maximum permitted interval between consecutive messages |
| `required` | bool | Whether a topic named literally must be present. Default `true` |
| `check_stamps` | bool | Validate `header.stamp` ordering. Default `false`, as it deserializes payloads |

### Transforms

Required frame pairs are checked for availability across the recording:

```yaml
transforms:
  required:
    - [camera_link, base_link]
  max_stale_ms: 500
```

Each pair is resolved through the transform tree, in either direction, since a
transform can be inverted. `/tf_static` is treated as valid for the whole
recording. A dynamic edge is usable from one update to the next and for
`max_stale_ms` after its last, which defaults to 500. Reading transforms
requires deserializing `/tf`, so it happens only when `required` is non-empty.

## Output

```console
$ baglint run_028.mcap --spec examples/demo_spec.yaml
run_028.mcap
  6 topics · 86,095 messages · 2m00s

FAIL /camera/image
  rate 24.0 Hz below minimum 25 Hz  [log_time]

FAIL /diagnostics
  only 1 message(s); too few to measure a rate against the 1 Hz minimum  [log_time]

FAIL /imu
  1 stamp(s) moved backwards (worst 190.0 ms at 50.00 s)  [header.stamp]

FAIL /joint_states
  2 missing interval(s) >10 ms (worst 112.0 ms at 88.50 s)  [log_time]

FAIL /scan
  required by spec but no messages in bag

FAIL /tf
  camera_link -> base_link unavailable for 13.52 s across 1 interval(s) (worst 13.52 s at 60.48 s)  [header.stamp]

WARN /imu
  18 message(s) repeated the previous stamp  [header.stamp]

WARN /imu
  6 message(s) carried a zero stamp  [header.stamp]

8 findings: 6 FAIL, 2 WARN
```

To regenerate the recording used above:

```console
$ python examples/make_demo_bag.py run_028.mcap
```

`--format html` writes a self-contained report with one track per topic on a
shared time axis, showing where each defect falls and whether defects on
different topics coincide:

```console
$ baglint run_028.mcap --spec examples/demo_spec.yaml --format html > report.html
```

![baglint HTML report](https://raw.githubusercontent.com/catplotlib/baglint/main/docs/report.png)

Grey marks when a topic was recorded, red a failure, amber a warning. A topic
struck through was never recorded at all. The report embeds its styles and
fetches nothing, so it can be published as a CI artifact.

`--format json` emits the same findings for machine consumption. Each carries a
stable `code`, intended for filtering and baselining in CI:

| Code | Level | Meaning |
| --- | --- | --- |
| `gap` | FAIL | Consecutive messages exceeded `max_gap_ms` |
| `rate_below_min` | FAIL | Mean rate fell below `min_rate` |
| `rate_unmeasurable` | FAIL | Fewer than two messages, so no rate can be computed |
| `missing_topic` | FAIL | A required topic carried no messages |
| `stamp_backwards` | FAIL | A `header.stamp` preceded the stamp before it |
| `stamp_duplicate` | WARN | A message repeated the previous `header.stamp` |
| `stamp_unset` | WARN | A message carried a zero `header.stamp` |
| `stamp_unavailable` | WARN | `check_stamps` was set on a message type without a header |
| `tf_unavailable` | FAIL | A required frame pair was unresolvable for some span |
| `tf_frame_missing` | FAIL | A required frame never appeared in the transform tree |

## Message timestamps

Each message carries two timestamps, and findings state which one was used:

| Timestamp | Set by | A defect indicates |
| --- | --- | --- |
| `log_time` | the recorder, on write | the recorder stalled: disk I/O, CPU starvation, a terminated node |
| `header.stamp` | the publisher, at sample time | the sensor or its driver misbehaved |

Gap and rate checks read `log_time`. Stamp checks read `header.stamp` and are
enabled per topic with `check_stamps`, which is off by default because it
requires deserializing every message on that topic:

```yaml
topics:
  /imu:
    check_stamps: true
```

A stamp that moves backwards is reported as a failure. Downstream consumers
such as tf2, `message_filters` and the SLAM backends assume a non-decreasing
stamp sequence per topic, and do not report the violation themselves. Duplicate and zero stamps
are reported as warnings.

A zero stamp is treated as never populated rather than as a timestamp, so it
does not become the baseline for the messages that follow it.

## Exit codes

| Code | Condition |
| --- | --- |
| 0 | No `FAIL` findings. With `--strict`, no `WARN` findings either |
| 1 | At least one finding at or above the failing level |
| 2 | Invalid arguments, unreadable recording, or malformed specification |

## Development

```console
$ make install
$ make test
```

Tests construct MCAP recordings with injected defects at known offsets. See
`tests/fixtures.py`.
