Skip to content

Commit 31a27b3

Browse files
rohan2017claude
andcommitted
test: parallelize suite, tier tests, and trim noise_fit cost
The suite had grown to ~8min serially. Cut it to <2min and add a sub-20s inner loop without losing coverage: - Enable pytest-xdist by default (addopts = -n auto). - Add slow/cpp markers via a centralized conftest auto-marker so `pytest -m "not slow and not cpp"` runs the fast 516-test inner loop. - Shrink the NoiseFit recovery/uninformed solves (fixed-seed, so margins stay deterministic): the module fixture solve drops ~165s -> ~57s while keeping multi-window coverage and a 3% error vs the 20% bar. - Drop the redundant Madgwick C++ roundtrip: it compiled the same lower_recurrence path PID covers and Mahony already roundtrips a quaternion output through C++. Numpy behavior + emit smoke tests kept. Co-Authored-By: Claude Opus 4.8 <[email protected]>
1 parent d78a765 commit 31a27b3

4 files changed

Lines changed: 73 additions & 83 deletions

File tree

pyproject.toml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,3 +30,11 @@ dev = ["pytest>=9.0", "pytest-xdist>=3.8"]
3030

3131
[tool.hatch.build.targets.wheel]
3232
packages = ["manta"]
33+
34+
[tool.pytest.ini_options]
35+
# Run in parallel by default (pytest-xdist). Override with `-n0` to serialize.
36+
addopts = "-n auto"
37+
markers = [
38+
"slow: long-running fits, Monte-Carlo, or fine-dt convergence sweeps",
39+
"cpp: shells out to a C/C++ compiler (codegen roundtrips)",
40+
]

tests/conftest.py

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
"""Shared pytest configuration: auto-marking of slow / cpp tests.
2+
3+
Rather than scatter ``@pytest.mark`` decorators across the suite, the policy
4+
for which tests are expensive lives here in one auditable place. Two markers
5+
are applied at collection time:
6+
7+
* ``cpp`` — the test shells out to a C/C++ compiler (codegen roundtrips,
8+
syntax checks). These already self-skip when no toolchain is on PATH; the
9+
marker lets you *also* exclude them when a compiler *is* present.
10+
* ``slow`` — long-running fits, Monte-Carlo consistency runs, or fine-dt
11+
convergence sweeps.
12+
13+
Fast inner-loop run: ``pytest -m "not slow and not cpp"``
14+
Full suite (CI): ``pytest`` (markers are not filtered)
15+
"""
16+
17+
# Tests whose *name* contains any of these substrings invoke a compiler.
18+
_CPP_NAME_MARKERS = (
19+
"cpp_roundtrip", # *_python_cpp_roundtrip across pid/madgwick/etc.
20+
"multicraft_roundtrip",
21+
"compiles_with_cc",
22+
"emits_scalar_ref", # test_codegen_emit_cpp _syntax_check tests
23+
)
24+
25+
# Whole modules dominated by long fits / Monte-Carlo work.
26+
_SLOW_MODULES = frozenset({
27+
"test_noise_fit",
28+
"test_consistency",
29+
"test_fit",
30+
})
31+
32+
# Individual slow tests living in otherwise-fast modules (long sims / sweeps).
33+
_SLOW_NAME_MARKERS = (
34+
"frictionless_energy_converges_with_dt",
35+
"double_pendulum_conserves_energy_undamped",
36+
"double_pendulum_converges_to_textbook_rk4",
37+
"gimbal_pendulum_precesses_at_minus_omega",
38+
"no_precession_without_hub_spin",
39+
"gimbal_conserves_momentum_and_energy",
40+
"eskf_nees_consistency_over_seeds",
41+
"random_walk_bias_walks_with_driver",
42+
"grad_through_rollout_is_finite",
43+
)
44+
45+
46+
def pytest_collection_modifyitems(config, items):
47+
import pytest
48+
49+
for item in items:
50+
module = item.module.__name__.rsplit(".", 1)[-1]
51+
name = item.name
52+
if any(s in name for s in _CPP_NAME_MARKERS):
53+
item.add_marker(pytest.mark.cpp)
54+
if module in _SLOW_MODULES or any(s in name for s in _SLOW_NAME_MARKERS):
55+
item.add_marker(pytest.mark.slow)

tests/test_madgwick.py

Lines changed: 7 additions & 80 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,12 @@
1-
"""Madgwick AHRS recurrence block — numpy behavior + C++ roundtrip.
1+
"""Madgwick AHRS recurrence block — numpy behavior + codegen smoke.
22
3-
Madgwick adds zero backend code (it reuses the generic `lower_recurrence`
4-
path PID established). These tests pin the convention (gyro integration,
5-
accelerometer convergence, quaternion stays unit) and prove the compiled
6-
C++ `step()` reproduces the numpy quaternion trajectory exactly.
3+
Madgwick adds zero backend code: it reuses the generic `lower_recurrence`
4+
path PID establishes and Mahony already roundtrips through compiled C++
5+
(quaternion output included). So there's no separate Madgwick C++ roundtrip
6+
— these tests pin the convention (gyro integration, accelerometer
7+
convergence, quaternion stays unit) and that the C++ target emits its files.
78
"""
89

9-
import shutil
10-
import subprocess
1110
from pathlib import Path
1211

1312
import numpy as np
@@ -61,83 +60,11 @@ def test_madgwick_quaternion_stays_unit():
6160
assert np.linalg.norm(q) == pytest.approx(1.0, abs=1e-9)
6261

6362

64-
# --- C++ roundtrip ----------------------------------------------------------
65-
66-
_GYRO = [[0.1, -0.2, 0.05], [0.3, 0.1, -0.1], [-0.2, 0.2, 0.15],
67-
[0.0, -0.3, 0.2], [0.25, 0.1, -0.05]]
68-
_ACCEL = [[0.0, 0.0, 9.81], [0.5, 0.0, 9.7], [0.3, -0.4, 9.6],
69-
[-0.2, 0.3, 9.75], [0.1, 0.1, 9.8]]
70-
_DT = 0.02
71-
72-
_HARNESS = r"""
73-
#include "madgwick.hpp"
74-
#include <cstdio>
75-
76-
int main() {
77-
manta_gen::Madgwick f;
78-
const double g[5][3] = {{0.1,-0.2,0.05},{0.3,0.1,-0.1},{-0.2,0.2,0.15},
79-
{0.0,-0.3,0.2},{0.25,0.1,-0.05}};
80-
const double a[5][3] = {{0.0,0.0,9.81},{0.5,0.0,9.7},{0.3,-0.4,9.6},
81-
{-0.2,0.3,9.75},{0.1,0.1,9.8}};
82-
for (int i = 0; i < 5; ++i) {
83-
manta_gen::Madgwick::Inputs u;
84-
u.gyro << g[i][0], g[i][1], g[i][2];
85-
u.accel << a[i][0], a[i][1], a[i][2];
86-
auto o = f.step(u, 0.02, 0.0);
87-
std::printf("q %.17g %.17g %.17g %.17g\n",
88-
o.orientation[0], o.orientation[1],
89-
o.orientation[2], o.orientation[3]);
90-
}
91-
return 0;
92-
}
93-
"""
63+
# --- codegen smoke ----------------------------------------------------------
9464

9565

9666
def test_madgwick_emits_cpp_files(tmp_path: Path):
9767
result = TargetCpp(Madgwick(beta=0.1), tmp_path, class_name="Madgwick")
9868
for p in (result.kernels_c, result.kernels_h, result.wrapper_hpp,
9969
result.wrapper_cpp, result.cmakelists):
10070
assert p.exists(), p
101-
102-
103-
def test_madgwick_python_cpp_roundtrip(tmp_path: Path):
104-
cxx = next((c for c in ("c++", "g++", "clang++") if shutil.which(c)), None)
105-
cc = next((c for c in ("cc", "gcc", "clang") if shutil.which(c)), None)
106-
if cxx is None or cc is None:
107-
pytest.skip("no C/C++ compiler on PATH")
108-
eigen_inc = next((p for p in ("/usr/include/eigen3",
109-
"/usr/local/include/eigen3")
110-
if Path(p, "Eigen", "Dense").exists()), None)
111-
if eigen_inc is None:
112-
pytest.skip("Eigen headers not found")
113-
114-
f = Madgwick(beta=0.1)
115-
result = TargetCpp(f, tmp_path, class_name="Madgwick")
116-
117-
k_obj, w_obj = tmp_path / "k.o", tmp_path / "w.o"
118-
for cmd in (
119-
[cc, "-c", "-O2", "-fPIC", str(result.kernels_c), "-o", str(k_obj)],
120-
[cxx, "-c", "-std=c++17", "-O2", "-fPIC", f"-I{eigen_inc}",
121-
f"-I{tmp_path}", str(result.wrapper_cpp), "-o", str(w_obj)],
122-
):
123-
p = subprocess.run(cmd, capture_output=True, text=True)
124-
assert p.returncode == 0, p.stderr
125-
126-
h_src = tmp_path / "harness_main.cpp"
127-
h_src.write_text(_HARNESS)
128-
binary = tmp_path / "harness"
129-
p = subprocess.run(
130-
[cxx, "-std=c++17", "-O2", f"-I{eigen_inc}", f"-I{tmp_path}",
131-
str(h_src), str(w_obj), str(k_obj), "-o", str(binary)],
132-
capture_output=True, text=True)
133-
assert p.returncode == 0, p.stderr
134-
p = subprocess.run([str(binary)], capture_output=True, text=True)
135-
assert p.returncode == 0, p.stderr
136-
cpp_q = [[float(x) for x in line.split()[1:]]
137-
for line in p.stdout.strip().splitlines()]
138-
139-
r = TargetNumpy(f)
140-
np_q = [list(r.step(_DT, gyro=g, accel=a)["orientation"])
141-
for g, a in zip(_GYRO, _ACCEL)]
142-
143-
np.testing.assert_allclose(cpp_q, np_q, atol=1e-12)

tests/test_noise_fit.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ def _record(world, n_win=3, K=120, seed=5):
4747
@pytest.fixture(scope="module")
4848
def fitted():
4949
"""One shared recovery solve: model starts 5x off, each way."""
50-
windows = _record(_noisy_drone(), n_win=2, K=100)
50+
windows = _record(_noisy_drone(), n_win=2, K=35)
5151
model = _noisy_drone(gyro=0.02, accel=0.01)
5252
nf = NoiseFit(model, noise={
5353
"imu.gyro_noise": Prior(sigma=2.0),
@@ -60,7 +60,7 @@ def test_noise_fit_recovers_sigmas(fitted):
6060
_, res = fitted
6161
g = res.values["drone.imu.gyro_noise"]
6262
a = res.values["drone.imu.accel_noise"]
63-
# ~200 samples/axis: expect σ to ~10%; assert 20%.
63+
# ~70 samples/axis: expect σ to ~15%; assert 20%.
6464
assert abs(g - TRUE_GYRO) / TRUE_GYRO < 0.2, g
6565
assert abs(a - TRUE_ACCEL) / TRUE_ACCEL < 0.2, a
6666
# The data informed both: posterior ≪ prior.
@@ -167,7 +167,7 @@ def test_noise_fit_uninformed_channel_posterior_stays_at_prior():
167167
noise under dominant measurement noise, short window): its Laplace
168168
posterior must come back ≈ the prior — 'this σ is your prior
169169
talking' — while the measurement channels are pinned down."""
170-
windows = _record(_noisy_drone(), n_win=1, K=60, seed=11)
170+
windows = _record(_noisy_drone(), n_win=1, K=40, seed=11)
171171
model = _noisy_drone()
172172
t1 = next(p for p in model.crafts[0].parts if p.name == "t1")
173173
t1.force_noise_sigma = 1e-4 # active but ~invisible

0 commit comments

Comments
 (0)