From ae7c30e3ca93531644034b4265759c773766a5c5 Mon Sep 17 00:00:00 2001 From: Alireza Naseri Date: Thu, 22 Oct 2026 23:29:08 +0330 Subject: [PATCH 1/4] Parametrize sim tests over integrators Extend the test suite to cover the different integrators, as requested in #106: - test_sim_step now runs the full pipeline for every integrator - test_floor_penetration now runs for every integrator - add test_free_fall_convergence, which verifies that the trajectory under every integrator converges to a 10x higher frequency reference sim in free fall. This catches integration breakage like the broken symplectic integrators observed in #102 --- tests/unit/test_sim.py | 60 +++++++++++++++++++++++++++++++++++++++--- 1 file changed, 56 insertions(+), 4 deletions(-) diff --git a/tests/unit/test_sim.py b/tests/unit/test_sim.py index 5fe1dc9ed..10764a75e 100644 --- a/tests/unit/test_sim.py +++ b/tests/unit/test_sim.py @@ -214,17 +214,30 @@ def test_reset_masked(device: str, dynamics: Dynamics): @pytest.mark.unit +@pytest.mark.parametrize("integrator", Integrator) @pytest.mark.parametrize("n_worlds", [1, 2]) @pytest.mark.parametrize("n_drones", [1, 3]) @pytest.mark.parametrize("dynamics", Dynamics) @pytest.mark.parametrize("control", Control) -def test_sim_step(n_worlds: int, n_drones: int, dynamics: Dynamics, control: Control, device: str): +def test_sim_step( + n_worlds: int, + n_drones: int, + dynamics: Dynamics, + control: Control, + integrator: Integrator, + device: str, +): if dynamics != Dynamics.first_principles: if control in (Control.body_rate, Control.force_torque, Control.rotor_vel): pytest.skip(f"{control} is not supported with non-first-principles dynamics") sim = Sim( - n_worlds=n_worlds, n_drones=n_drones, dynamics=dynamics, device=device, control=control + n_worlds=n_worlds, + n_drones=n_drones, + dynamics=dynamics, + device=device, + control=control, + integrator=integrator, ) sim.step(2) @@ -423,14 +436,17 @@ def test_seed_reset(): @pytest.mark.unit +@pytest.mark.parametrize("integrator", Integrator) @pytest.mark.parametrize("dynamics", Dynamics) -def test_floor_penetration(dynamics: Dynamics): +def test_floor_penetration(dynamics: Dynamics, integrator: Integrator): """Test that drones cannot penetrate the floor (z < 0.01). We don't test for mujoco, as mujoco uses collisions by default and will let the drone bounce on the floor. """ - sim = Sim(dynamics=dynamics, control=Control.attitude, freq=500, device="cpu") + sim = Sim( + dynamics=dynamics, control=Control.attitude, integrator=integrator, freq=500, device="cpu" + ) sim.reset() # Command to fall: zero thrust and attitude that points downward attitude_cmd = np.zeros((1, 1, 4)) # [roll, pitch, yaw, thrust] @@ -448,6 +464,42 @@ def test_floor_penetration(dynamics: Dynamics): sim.close() +@pytest.mark.unit +@pytest.mark.parametrize("integrator", Integrator) +@pytest.mark.parametrize("dynamics", Dynamics) +def test_free_fall_convergence(dynamics: Dynamics, integrator: Integrator): + """Test that every integrator converges to a high-frequency reference in free fall. + + Regression test for #102: a broken symplectic integrator went unnoticed because no test + exercised the dynamics under all integration schemes. Commands are clipped to the motor limits, + so the minimum thrust is applied instead of true zero thrust. The trajectory at the default + frequency must match a 10x higher frequency reference within the leading discretization error. + """ + freq, ref_freq, t_fall = 500, 5000, 0.5 + sims = [ + Sim( + dynamics=dynamics, control=Control.attitude, integrator=integrator, freq=f, device="cpu" + ) + for f in (freq, ref_freq) + ] + for s in sims: + s.reset() + states = s.data.states.replace(pos=s.data.states.pos.at[..., 2].set(2.0)) + s.data = s.data.replace(states=states) + s.attitude_control(np.zeros((1, 1, 4))) # Command minimum thrust to fall + n_steps = int(freq * t_fall) + sims[0].step(n_steps) + sims[1].step(n_steps * ref_freq // freq) + z, z_ref = sims[0].data.states.pos[..., 2], sims[1].data.states.pos[..., 2] + assert jnp.all(z > 0), "Drone hit the floor; fall distance is too large for this test" + # Leading order discretization error difference ~ 0.5 * a_eff * t * dt + a_eff = 2 * (2.0 - float(z_ref.min())) / t_fall**2 + atol = max(0.5 * a_eff * t_fall / freq, 0.01) + assert jnp.allclose(z, z_ref, atol=atol), f"Free fall mismatch: {z} vs {z_ref} (atol={atol})" + sims[0].close() + sims[1].close() + + @pytest.mark.unit @pytest.mark.parametrize("integrator", Integrator) def test_rotor_vel_clip(integrator: Integrator): From 4b48be40431fdf8689766b7fd59f9e4faa89bc30 Mon Sep 17 00:00:00 2001 From: Alireza Naseri Date: Fri, 23 Oct 2026 09:10:05 +0330 Subject: [PATCH 2/4] Address review feedback on integrator test coverage Per the review, rework the approach to follow the one sketched in #122: - Revert the integrator parametrization of test_sim_step and test_floor_penetration, and drop the free-fall convergence test. The parametrized test_sim_step alone added 240 test variants (40 -> 280) and roughly doubled the unit test duration on CPU. - Add tests/unit/test_integrators.py instead, which compares the complete sim.data pytree of every dynamics x integrator combination against a separately stepped default-integrator Sim after two steps. This adds 12 fast variants (~13 s on CPU) and covers the so_rpy families where the broken symplectic integrator from #102 lived. --- tests/unit/test_integrators.py | 42 ++++++++++++++++++++++++ tests/unit/test_sim.py | 60 +++------------------------------- 2 files changed, 46 insertions(+), 56 deletions(-) create mode 100644 tests/unit/test_integrators.py diff --git a/tests/unit/test_integrators.py b/tests/unit/test_integrators.py new file mode 100644 index 000000000..5e385770f --- /dev/null +++ b/tests/unit/test_integrators.py @@ -0,0 +1,42 @@ +"""Unit tests for simulation integrators.""" + +import jax +import jax.numpy as jnp +import numpy as np +import pytest + +from crazyflow.sim import Dynamics, Sim +from crazyflow.sim.integration import Integrator + + +@pytest.mark.unit +@pytest.mark.parametrize("integrator", Integrator) +@pytest.mark.parametrize("dynamics", Dynamics) +def test_integrators_close_to_default(dynamics: Dynamics, integrator: Integrator, device: str): + """Every integrator stays close to the default integrator after two simulation steps.""" + sim = Sim(dynamics=dynamics, integrator=integrator, device=device) + default_sim = Sim(dynamics=dynamics, device=device) + sim.step(2) + default_sim.step(2) + + data, structure = jax.tree.flatten_with_path(sim.data) + default_data, default_structure = jax.tree.flatten(default_sim.data) + assert structure == default_structure, "Simulation data structures must match" + for (path, value), default_value in zip(data, default_data, strict=True): + name = jax.tree_util.keystr(path) + if isinstance(value, jnp.ndarray): + if jax.dtypes.issubdtype(value.dtype, jax.dtypes.prng_key): + np.testing.assert_array_equal( + jax.random.key_data(value), jax.random.key_data(default_value), err_msg=name + ) + elif jnp.issubdtype(value.dtype, jnp.inexact): + # Rotor speeds differ by several percent between Euler and RK4. + np.testing.assert_allclose( + value, default_value, rtol=2e-2, atol=1e-4, err_msg=name, strict=True + ) + else: + np.testing.assert_array_equal(value, default_value, err_msg=name, strict=True) + else: + assert value == default_value, f"{name}: Value mismatch" + sim.close() + default_sim.close() diff --git a/tests/unit/test_sim.py b/tests/unit/test_sim.py index 10764a75e..5fe1dc9ed 100644 --- a/tests/unit/test_sim.py +++ b/tests/unit/test_sim.py @@ -214,30 +214,17 @@ def test_reset_masked(device: str, dynamics: Dynamics): @pytest.mark.unit -@pytest.mark.parametrize("integrator", Integrator) @pytest.mark.parametrize("n_worlds", [1, 2]) @pytest.mark.parametrize("n_drones", [1, 3]) @pytest.mark.parametrize("dynamics", Dynamics) @pytest.mark.parametrize("control", Control) -def test_sim_step( - n_worlds: int, - n_drones: int, - dynamics: Dynamics, - control: Control, - integrator: Integrator, - device: str, -): +def test_sim_step(n_worlds: int, n_drones: int, dynamics: Dynamics, control: Control, device: str): if dynamics != Dynamics.first_principles: if control in (Control.body_rate, Control.force_torque, Control.rotor_vel): pytest.skip(f"{control} is not supported with non-first-principles dynamics") sim = Sim( - n_worlds=n_worlds, - n_drones=n_drones, - dynamics=dynamics, - device=device, - control=control, - integrator=integrator, + n_worlds=n_worlds, n_drones=n_drones, dynamics=dynamics, device=device, control=control ) sim.step(2) @@ -436,17 +423,14 @@ def test_seed_reset(): @pytest.mark.unit -@pytest.mark.parametrize("integrator", Integrator) @pytest.mark.parametrize("dynamics", Dynamics) -def test_floor_penetration(dynamics: Dynamics, integrator: Integrator): +def test_floor_penetration(dynamics: Dynamics): """Test that drones cannot penetrate the floor (z < 0.01). We don't test for mujoco, as mujoco uses collisions by default and will let the drone bounce on the floor. """ - sim = Sim( - dynamics=dynamics, control=Control.attitude, integrator=integrator, freq=500, device="cpu" - ) + sim = Sim(dynamics=dynamics, control=Control.attitude, freq=500, device="cpu") sim.reset() # Command to fall: zero thrust and attitude that points downward attitude_cmd = np.zeros((1, 1, 4)) # [roll, pitch, yaw, thrust] @@ -464,42 +448,6 @@ def test_floor_penetration(dynamics: Dynamics, integrator: Integrator): sim.close() -@pytest.mark.unit -@pytest.mark.parametrize("integrator", Integrator) -@pytest.mark.parametrize("dynamics", Dynamics) -def test_free_fall_convergence(dynamics: Dynamics, integrator: Integrator): - """Test that every integrator converges to a high-frequency reference in free fall. - - Regression test for #102: a broken symplectic integrator went unnoticed because no test - exercised the dynamics under all integration schemes. Commands are clipped to the motor limits, - so the minimum thrust is applied instead of true zero thrust. The trajectory at the default - frequency must match a 10x higher frequency reference within the leading discretization error. - """ - freq, ref_freq, t_fall = 500, 5000, 0.5 - sims = [ - Sim( - dynamics=dynamics, control=Control.attitude, integrator=integrator, freq=f, device="cpu" - ) - for f in (freq, ref_freq) - ] - for s in sims: - s.reset() - states = s.data.states.replace(pos=s.data.states.pos.at[..., 2].set(2.0)) - s.data = s.data.replace(states=states) - s.attitude_control(np.zeros((1, 1, 4))) # Command minimum thrust to fall - n_steps = int(freq * t_fall) - sims[0].step(n_steps) - sims[1].step(n_steps * ref_freq // freq) - z, z_ref = sims[0].data.states.pos[..., 2], sims[1].data.states.pos[..., 2] - assert jnp.all(z > 0), "Drone hit the floor; fall distance is too large for this test" - # Leading order discretization error difference ~ 0.5 * a_eff * t * dt - a_eff = 2 * (2.0 - float(z_ref.min())) / t_fall**2 - atol = max(0.5 * a_eff * t_fall / freq, 0.01) - assert jnp.allclose(z, z_ref, atol=atol), f"Free fall mismatch: {z} vs {z_ref} (atol={atol})" - sims[0].close() - sims[1].close() - - @pytest.mark.unit @pytest.mark.parametrize("integrator", Integrator) def test_rotor_vel_clip(integrator: Integrator): From bc7776c5cf1be3ac33ae5a206bb4ab87887c5565 Mon Sep 17 00:00:00 2001 From: Alireza Naseri Date: Fri, 23 Oct 2026 13:38:11 +0330 Subject: [PATCH 3/4] Spawn drones in the air in the integrator comparison test At the default spawn on the floor, floor clipping masks any difference in the dynamics between integrators. Spawn both sims at z=2.0 instead. --- tests/unit/test_integrators.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/unit/test_integrators.py b/tests/unit/test_integrators.py index 5e385770f..1339a46a9 100644 --- a/tests/unit/test_integrators.py +++ b/tests/unit/test_integrators.py @@ -16,6 +16,10 @@ def test_integrators_close_to_default(dynamics: Dynamics, integrator: Integrator """Every integrator stays close to the default integrator after two simulation steps.""" sim = Sim(dynamics=dynamics, integrator=integrator, device=device) default_sim = Sim(dynamics=dynamics, device=device) + for s in (sim, default_sim): + # Spawn in the air; at the default spawn on the floor, clipping would mask differences + states = s.data.states.replace(pos=s.data.states.pos.at[..., 2].set(2.0)) + s.data = s.data.replace(states=states) sim.step(2) default_sim.step(2) From ccf664515223b9021296fb1b517c205c911f5f76 Mon Sep 17 00:00:00 2001 From: Martin Schuck <57562633+amacati@users.noreply.github.com> Date: Fri, 23 Oct 2026 13:41:05 +0200 Subject: [PATCH 4/4] Reword comment --- tests/unit/test_integrators.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/unit/test_integrators.py b/tests/unit/test_integrators.py index 1339a46a9..c8f9a50eb 100644 --- a/tests/unit/test_integrators.py +++ b/tests/unit/test_integrators.py @@ -16,8 +16,8 @@ def test_integrators_close_to_default(dynamics: Dynamics, integrator: Integrator """Every integrator stays close to the default integrator after two simulation steps.""" sim = Sim(dynamics=dynamics, integrator=integrator, device=device) default_sim = Sim(dynamics=dynamics, device=device) - for s in (sim, default_sim): - # Spawn in the air; at the default spawn on the floor, clipping would mask differences + for s in (sim, default_sim): + # Spawn the drone in the air to induce motion during the steps states = s.data.states.replace(pos=s.data.states.pos.at[..., 2].set(2.0)) s.data = s.data.replace(states=states) sim.step(2)