Repository: NCSU-High-Powered-Rocketry-Club/AirbrakesV2

Issue:
`LandedState` should be detected based on acceleration spike
Genesis Flight 1 had trouble switching to `LandedState`, because the velocity calculated from the gyro was off by > 20m/s due to drift. Genesis Flight 2 lucked out and the velocity was mostly accurate and consistent with velocity from the altitude data. 

Accurately detecting landing is critical because we have to stop logging. The only safe and sensible way to detect landing is based on the acceleration spike (along with height lower than `LANDED_ALTITUDE` =~ 10m). 

The same point can be made for detecting MotorBurn, where we currently switch to MotorBurn a bit too slowly, e.g. it takes 50 est packets to switch whereas we could've done it in 6 est packets (where the accel was -30m/s^2). So we were about 10x slower to switch states.

Discussion:

Patch:
diff --git a/airbrakes/constants.py b/airbrakes/constants.py
index f76ab2f..8f6d457 100644
--- a/airbrakes/constants.py
+++ b/airbrakes/constants.py
@@ -158,9 +158,8 @@ deploying at apogee."""
 
 GROUND_ALTITUDE_METERS = 10.0
 """The altitude in meters that the rocket must be under before we consider it to have landed."""
-LANDED_SPEED_METERS_PER_SECOND = 5.0
-"""The speed in meters per second that the rocket must be under before we consider it to have
-landed."""
+LANDED_ACCELERATION_METERS_PER_SECOND_SQUARED = 50.0
+"""The acceleration in m/s^2 that the rocket must be above before we consider it to have landed."""
 
 # -------------------------------------------------------
 # Apogee Prediction Configuration
diff --git a/airbrakes/data_handling/data_processor.py b/airbrakes/data_handling/data_processor.py
index 3d0d1b4..67e3c34 100644
--- a/airbrakes/data_handling/data_processor.py
+++ b/airbrakes/data_handling/data_processor.py
@@ -88,6 +88,11 @@ class IMUDataProcessor:
         """The maximum vertical velocity the rocket has attained during the flight, in m/s."""
         return float(self._max_vertical_velocity)
 
+    @property
+    def average_vertical_acceleration(self) -> float:
+        """The average vertical acceleration of the rocket in m/s^2."""
+        return float(np.mean(self._rotated_accelerations))
+
     @property
     def current_timestamp(self) -> int:
         """The timestamp of the last data packet in nanoseconds."""
diff --git a/airbrakes/state.py b/airbrakes/state.py
index 3bf2770..87c9e65 100644
--- a/airbrakes/state.py
+++ b/airbrakes/state.py
@@ -5,7 +5,7 @@ from typing import TYPE_CHECKING
 
 from airbrakes.constants import (
     GROUND_ALTITUDE_METERS,
-    LANDED_SPEED_METERS_PER_SECOND,
+    LANDED_ACCELERATION_METERS_PER_SECOND_SQUARED,
     MAX_FREE_FALL_SECONDS,
     MAX_VELOCITY_THRESHOLD,
     TAKEOFF_HEIGHT_METERS,
@@ -180,10 +180,10 @@ class FreeFallState(State):
 
         data = self.context.data_processor
 
-        # If our altitude and speed are around 0, we have landed
+        # If our altitude is around 0, and we have an acceleration spike, we have landed
         if (
             data.current_altitude <= GROUND_ALTITUDE_METERS
-            and abs(data.vertical_velocity) <= LANDED_SPEED_METERS_PER_SECOND
+            and data.average_vertical_acceleration >= LANDED_ACCELERATION_METERS_PER_SECOND_SQUARED
         ):
             self.next_state()
 


Test patch:
diff --git a/tests/test_data_processor.py b/tests/test_data_processor.py
index 5303822..aa9591b 100644
--- a/tests/test_data_processor.py
+++ b/tests/test_data_processor.py
@@ -145,6 +145,7 @@ class TestIMUDataProcessor:
         assert d.vertical_velocity == 0.0
         assert d.max_vertical_velocity == 0.0
         assert d.current_timestamp == 0
+        assert d.average_vertical_acceleration == 0.0
 
     def test_str(self, data_processor):
         data_str = (
@@ -362,6 +363,8 @@ class TestIMUDataProcessor:
             assert data.vertical_acceleration == d._rotated_accelerations[idx]
             assert data.time_since_last_data_packet == d._time_differences[idx]
 
+        assert d.average_vertical_acceleration == np.mean(d._rotated_accelerations)
+
     @pytest.mark.parametrize(
         # altitude reading - list of altitudes passed to the data processor (estPressureAlt)
         # current_altitude - calculated current altitude of the rocket, zeroed out.
diff --git a/tests/test_integration.py b/tests/test_integration.py
index 7beb8d0..05082cb 100644
--- a/tests/test_integration.py
+++ b/tests/test_integration.py
@@ -13,14 +13,14 @@ import pytest
 from airbrakes.airbrakes import AirbrakesContext
 from airbrakes.constants import (
     GROUND_ALTITUDE_METERS,
-    LANDED_SPEED_METERS_PER_SECOND,
+    LANDED_ACCELERATION_METERS_PER_SECOND_SQUARED,
     TAKEOFF_HEIGHT_METERS,
     TAKEOFF_VELOCITY_METERS_PER_SECOND,
     ServoExtension,
 )
 from airbrakes.data_handling.logged_data_packet import LoggedDataPacket
 
-SNAPSHOT_INTERVAL = 0.01  # seconds
+SNAPSHOT_INTERVAL = 0.001  # seconds
 
 
 class StateInformation(msgspec.Struct):
@@ -31,6 +31,8 @@ class StateInformation(msgspec.Struct):
     extensions: list[float] = []
     min_altitude: float | None = None
     max_altitude: float | None = None
+    min_avg_vertical_acceleration: float | None = None
+    max_avg_vertical_acceleration: float | None = None
     apogee_prediction: list[float] = []
 
 
@@ -95,6 +97,12 @@ class TestIntegration:
                     state_info.max_velocity = ab.data_processor.vertical_velocity
                     state_info.max_altitude = ab.data_processor.current_altitude
                     state_info.min_altitude = ab.data_processor.current_altitude
+                    state_info.min_avg_vertical_acceleration = (
+                        ab.data_processor.average_vertical_acceleration
+                    )
+                    state_info.max_avg_vertical_acceleration = (
+                        ab.data_processor.average_vertical_acceleration
+                    )
                     state_info.apogee_prediction.append(ab.apogee_predictor.apogee)
 
                 state_info.min_velocity = min(
@@ -110,6 +118,15 @@ class TestIntegration:
                 state_info.max_altitude = max(
                     ab.data_processor.current_altitude, state_info.max_altitude
                 )
+                state_info.min_avg_vertical_acceleration = min(
+                    ab.data_processor.average_vertical_acceleration,
+                    state_info.min_avg_vertical_acceleration,
+                )
+                state_info.max_avg_vertical_acceleration = max(
+                    ab.data_processor.average_vertical_acceleration,
+                    state_info.max_avg_vertical_acceleration,
+                )
+
                 state_info.apogee_prediction.append(ab.apogee_predictor.apogee)
 
                 # Update the state information in the dictionary
@@ -124,25 +141,15 @@ class TestIntegration:
         # Let's validate our data!
 
         # Check we have all the states:
-        if launch_name == "purple_launch":
-            # Our Launches don't properly log/reach the LandedState, so we will ignore it for now.
-            assert len(states_dict) == 4
-            assert list(states_dict.keys()) == [
-                "StandbyState",
-                "MotorBurnState",
-                "CoastState",
-                "FreeFallState",
-            ]
-        else:
-            assert len(states_dict) == 5
-            # Order of states matters!
-            assert list(states_dict.keys()) == [
-                "StandbyState",
-                "MotorBurnState",
-                "CoastState",
-                "FreeFallState",
-                "LandedState",
-            ]
+        assert len(states_dict) == 5
+        # Order of states matters!
+        assert list(states_dict.keys()) == [
+            "StandbyState",
+            "MotorBurnState",
+            "CoastState",
+            "FreeFallState",
+            "LandedState",
+        ]
         states_dict = types.SimpleNamespace(**states_dict)
         stand_by_state = states_dict.StandbyState
         motor_burn_state = states_dict.MotorBurnState
@@ -158,6 +165,7 @@ class TestIntegration:
         assert all(ext == ServoExtension.MIN_EXTENSION for ext in stand_by_state.extensions)
 
         assert motor_burn_state.min_velocity >= TAKEOFF_VELOCITY_METERS_PER_SECOND
+        assert motor_burn_state.max_avg_vertical_acceleration >= 90.0
         assert motor_burn_state.max_velocity <= 300.0  # arbitrary value, we haven't hit Mach 1
         assert motor_burn_state.min_altitude >= -2.5  # detecting takeoff from velocity data
         assert motor_burn_state.max_altitude >= TAKEOFF_HEIGHT_METERS
@@ -194,14 +202,15 @@ class TestIntegration:
         assert free_fall_state.min_altitude <= GROUND_ALTITUDE_METERS + 10.0
         assert all(ext == ServoExtension.MIN_EXTENSION for ext in free_fall_state.extensions)
 
-        if launch_name != "purple_launch":
-            # Generated data for simulated landing for interest launcH:
-            landed_state = states_dict.LandedState
-            assert landed_state.min_velocity >= -LANDED_SPEED_METERS_PER_SECOND
-            assert landed_state.max_velocity <= 0.1
-            assert landed_state.min_altitude <= GROUND_ALTITUDE_METERS
-            assert landed_state.max_altitude <= GROUND_ALTITUDE_METERS + 10.0
-            assert all(ext == ServoExtension.MIN_EXTENSION for ext in landed_state.extensions)
+        # Check landed state values:
+        landed_state = states_dict.LandedState
+        assert (
+            landed_state.max_avg_vertical_acceleration
+            >= LANDED_ACCELERATION_METERS_PER_SECOND_SQUARED
+        )
+        assert landed_state.min_altitude <= GROUND_ALTITUDE_METERS
+        assert landed_state.max_altitude <= GROUND_ALTITUDE_METERS + 10.0
+        assert all(ext == ServoExtension.MIN_EXTENSION for ext in landed_state.extensions)
 
         # Now let's check if everything was logged correctly:
         # some what of a duplicate of test_logger.py:
@@ -258,7 +267,4 @@ class TestIntegration:
             assert line_number > 80_000
 
             # Check if all states were logged:
-            if launch_name == "purple_launch":
-                assert state_list == ["S", "M", "C", "F"]
-            else:
-                assert state_list == ["S", "M", "C", "F", "L"]
+            assert state_list == ["S", "M", "C", "F", "L"]
diff --git a/tests/test_state.py b/tests/test_state.py
index de0516c..d74afd3 100644
--- a/tests/test_state.py
+++ b/tests/test_state.py
@@ -4,7 +4,7 @@ import pytest
 
 from airbrakes.constants import (
     GROUND_ALTITUDE_METERS,
-    LANDED_SPEED_METERS_PER_SECOND,
+    LANDED_ACCELERATION_METERS_PER_SECOND_SQUARED,
     LOG_BUFFER_SIZE,
     MAX_FREE_FALL_SECONDS,
     MAX_VELOCITY_THRESHOLD,
@@ -307,21 +307,41 @@ class TestFreeFallState:
         assert free_fall_state.name == "FreeFallState"
 
     @pytest.mark.parametrize(
-        ("current_altitude", "vertical_velocity", "expected_state", "time_length"),
+        ("current_altitude", "vertical_accel", "expected_state", "time_length"),
         [
-            (GROUND_ALTITUDE_METERS * 4, -(LANDED_SPEED_METERS_PER_SECOND * 4), FreeFallState, 1.0),
-            (GROUND_ALTITUDE_METERS * 2, -(LANDED_SPEED_METERS_PER_SECOND * 2), FreeFallState, 1.0),
-            (GROUND_ALTITUDE_METERS - 5, -(LANDED_SPEED_METERS_PER_SECOND * 2), FreeFallState, 1.0),
-            (GROUND_ALTITUDE_METERS - 5, LANDED_SPEED_METERS_PER_SECOND - 1.0, LandedState, 1.0),
             (
                 GROUND_ALTITUDE_METERS * 4,
-                -(LANDED_SPEED_METERS_PER_SECOND * 4),
+                LANDED_ACCELERATION_METERS_PER_SECOND_SQUARED / 4,
+                FreeFallState,
+                1.0,
+            ),
+            (
+                GROUND_ALTITUDE_METERS * 2,
+                LANDED_ACCELERATION_METERS_PER_SECOND_SQUARED / 2,
+                FreeFallState,
+                1.0,
+            ),
+            (
+                GROUND_ALTITUDE_METERS - 5,
+                LANDED_ACCELERATION_METERS_PER_SECOND_SQUARED / 2,
+                FreeFallState,
+                1.0,
+            ),
+            (
+                GROUND_ALTITUDE_METERS - 5,
+                LANDED_ACCELERATION_METERS_PER_SECOND_SQUARED,
+                LandedState,
+                1.0,
+            ),
+            (
+                GROUND_ALTITUDE_METERS * 4,
+                LANDED_ACCELERATION_METERS_PER_SECOND_SQUARED / 4,
                 FreeFallState,
                 MAX_FREE_FALL_SECONDS - 1.0,
             ),
             (
                 GROUND_ALTITUDE_METERS * 4,
-                -(LANDED_SPEED_METERS_PER_SECOND * 4),
+                LANDED_ACCELERATION_METERS_PER_SECOND_SQUARED * 4,
                 LandedState,
                 MAX_FREE_FALL_SECONDS,
             ),
@@ -336,10 +356,15 @@ class TestFreeFallState:
         ],
     )
     def test_update(
-        self, free_fall_state, current_altitude, vertical_velocity, expected_state, time_length
+        self,
+        free_fall_state,
+        current_altitude,
+        vertical_accel,
+        expected_state,
+        time_length,
     ):
         free_fall_state.context.data_processor._current_altitudes = [current_altitude]
-        free_fall_state.context.data_processor._vertical_velocities = [vertical_velocity]
+        free_fall_state.context.data_processor._rotated_accelerations = [vertical_accel]
         free_fall_state.start_time_ns = 0
         free_fall_state.context.data_processor._last_data_packet = EstimatedDataPacket(
             time_length * 1e9