From c9d01ec465d82a3e3ab256e6602dbda1fd113af1 Mon Sep 17 00:00:00 2001 From: mrsqr Date: Sun, 9 Aug 2026 22:53:19 +0100 Subject: [PATCH] Fix launch detection and cancellation --- README.md | 2 + User Guide.md | 20 +++++- launch.py | 137 +++++++++++++++++++++++++++++++++++----- main.py | 16 ++++- tests/test_launch.py | 144 ++++++++++++++++++++++++++++++++++++++++--- touch_drive.py | 4 +- 6 files changed, 295 insertions(+), 28 deletions(-) diff --git a/README.md b/README.md index de04d40..7649ee2 100644 --- a/README.md +++ b/README.md @@ -139,6 +139,8 @@ Version 3.2 uses two separate configuration scopes: * `params.json` contains system-owned choices and display behavior: `DURATION_VALUES`, `LAUNCH_SENSE_VALUES`, `VERSION`, `DISPLAY_DELAY_REST`, `DISPLAY_DELAY_REST_COLOUR`, and `BOOT_DELAY_SEC`. * `user.json` contains the current user selections: `RACE_LENGTH` (track-session minutes), `REST_LENGTH` (pit-rest minutes), and `SENSITIVITY` (launch threshold; `0` disables Launch Mode). +Launch sensitivity is the filtered change in acceleration-vector magnitude from a 0.4-second stationary baseline, measured in g. This removes gravity and mounting orientation and handles acceleration on either side of every axis. Lower non-zero values are more sensitive. Detection requires three consecutive samples above the threshold; double-tap cancels the wait, and a 30-second timeout returns to the Ready screen. See `User Guide.md` for the practical meaning of every configured value. + The firmware has built-in system and user defaults. Missing, malformed, or unsupported user values are replaced with safe defaults and saved using the canonical keys above. Existing `TRACK_LENGTH`, `TRACK_SESSION_LENGTH`, and `REST_SESSION_LENGTH` user keys are migrated automatically. ## Host-side tests diff --git a/User Guide.md b/User Guide.md index 6fa3100..23153a4 100644 --- a/User Guide.md +++ b/User Guide.md @@ -5,7 +5,8 @@ The following describes general operation of both the ``Track Session`` and ``Re * Upon start up a boot splash will be shown for 2 seconds. * After which the ``Primary Screen`` will be shown, detailing the current track session duration in minutes, and the statement ``Ready``. This indicates the timer is ready to start. To start the ``Track Session`` or race, ``Swipe Down``. -* After swiping down,``Go`` will display briefly. If ``Launch Mode`` has been activited, ``Lights`` will be displayed. The timer will commence upon sufficient acceleration. +* After swiping down, ``Go`` will display briefly. If ``Launch Mode`` has been activated, ``Lights`` will be displayed while the timer measures a stationary baseline and waits for sufficient acceleration. +* While waiting in ``Launch Mode``, double-tap to cancel and return to the ``Primary Screen``. The wait also cancels automatically after 30 seconds. * Upon starting, the ``Track Session`` timer count down will be displayed, and immediately commence. * At 85% completion of the ``Track Session`` the timer display colours will change to highlight progression. * At 95% completion of the ``Track Session`` the timer display colours will again change, further highlighting progression and final expiry warning. @@ -31,3 +32,20 @@ It is possible to change the duration of both the ``Track Session`` and the ``Re * Swipe ``Left`` or ``Right`` to select an appropriate value greater than zero. * Swipe ``UP`` to save and enable ``Launch mode``. * Select `0` and swipe ``UP`` to save and disable ``Launch mode``. + +Keep the timer stationary while ``Lights`` first appears. The firmware averages 20 samples over 0.4 seconds to remove gravity and the device's mounting orientation. It then measures the filtered change in the three-axis acceleration vector, so forward or reverse acceleration can trigger regardless of which way the display is mounted. A launch must remain above the threshold for three consecutive 20 ms samples; isolated vibration and bumps are ignored. + +Sensitivity values are acceleration changes in **g**, where approximately 1 g is Earth's gravitational acceleration. Lower non-zero values trigger more easily: + +| Value | Practical meaning | +| ---: | --- | +| `0` | Launch Mode disabled; timer starts immediately. | +| `0.5` | Very sensitive; suitable for moderate road-car launches. | +| `1` | Strong launch acceleration. | +| `1.25` | Aggressive launch. | +| `1.5` | Very aggressive launch. | +| `1.75` | Motorsport-level acceleration or a strong jolt. | +| `2` | High threshold; unlikely in normal road use. | +| `2.5` | Very high threshold; mainly abrupt impacts. | +| `3.5` | Extreme impact-level acceleration. | +| `4` | Maximum configured threshold; specialist use only. | diff --git a/launch.py b/launch.py index 1167e79..535404a 100644 --- a/launch.py +++ b/launch.py @@ -1,18 +1,127 @@ -"""Hardware-independent launch wait loop.""" +"""Orientation-independent, cancellable vehicle launch detection.""" +import math +import time -def accel_launch(qmi8658, sensitivity=0): - """Wait for the existing all-axis threshold condition. - The launch algorithm itself remains tracked by issue #3. Keeping the loop in - this importable module allows deterministic regression tests around it. +CALIBRATION_SAMPLES = 20 +SAMPLE_INTERVAL_MS = 20 +FILTER_ALPHA = 0.35 +TRIGGER_SAMPLES = 3 +DEFAULT_TIMEOUT_SEC = 30 + + +def _ticks_ms(clock): + ticks_ms = getattr(clock, "ticks_ms", None) + if ticks_ms is not None: + return ticks_ms() + monotonic = getattr(clock, "monotonic", None) + if monotonic is not None: + return int(monotonic() * 1000) + return int(clock.time() * 1000) + + +def _ticks_diff(clock, current, previous): + ticks_diff = getattr(clock, "ticks_diff", None) + if ticks_diff is not None: + return ticks_diff(current, previous) + return current - previous + + +def _sleep_ms(clock, milliseconds): + sleep_ms = getattr(clock, "sleep_ms", None) + if sleep_ms is not None: + sleep_ms(milliseconds) + else: + clock.sleep(milliseconds / 1000) + + +def _timed_out(clock, started_at, timeout_sec): + if timeout_sec is None: + return False + elapsed_ms = _ticks_diff(clock, _ticks_ms(clock), started_at) + return elapsed_ms >= int(timeout_sec * 1000) + + +def _should_exit(cancel_check, clock, started_at, timeout_sec): + return ( + (cancel_check is not None and cancel_check()) + or _timed_out(clock, started_at, timeout_sec) + ) + + +def _acceleration(sample): + if len(sample) < 3: + raise ValueError("Accelerometer sample must contain x, y, and z axes") + return float(sample[0]), float(sample[1]), float(sample[2]) + + +def accel_launch( + qmi8658, + sensitivity=0, + cancel_check=None, + timeout_sec=DEFAULT_TIMEOUT_SEC, + clock=time, + calibration_samples=CALIBRATION_SAMPLES, + sample_interval_ms=SAMPLE_INTERVAL_MS, + filter_alpha=FILTER_ALPHA, + trigger_samples=TRIGGER_SAMPLES, +): + """Wait for a sustained acceleration-vector change and return its outcome. + + ``sensitivity`` is a threshold in g relative to a stationary baseline. The + baseline removes gravity and device mounting orientation. Both positive and + negative acceleration are detected because the filtered vector magnitude is + used. ``False`` means the wait was cancelled or timed out. """ - ac_x = 0 - ac_y = 0 - ac_z = 0 - while ac_x < sensitivity or ac_y < sensitivity or ac_z < sensitivity: - xyz = qmi8658.Read_XYZ() - ac_x = xyz[0] - ac_y = xyz[1] - ac_z = xyz[2] - return True + threshold = float(sensitivity) + if threshold <= 0: + return True + if calibration_samples < 1: + raise ValueError("calibration_samples must be positive") + if trigger_samples < 1: + raise ValueError("trigger_samples must be positive") + if sample_interval_ms < 0: + raise ValueError("sample_interval_ms cannot be negative") + if filter_alpha <= 0 or filter_alpha > 1: + raise ValueError("filter_alpha must be greater than 0 and at most 1") + + started_at = _ticks_ms(clock) + baseline = [0.0, 0.0, 0.0] + for _ in range(calibration_samples): + if _should_exit(cancel_check, clock, started_at, timeout_sec): + return False + axes = _acceleration(qmi8658.Read_XYZ()) + baseline[0] += axes[0] + baseline[1] += axes[1] + baseline[2] += axes[2] + _sleep_ms(clock, sample_interval_ms) + + baseline[0] /= calibration_samples + baseline[1] /= calibration_samples + baseline[2] /= calibration_samples + + filtered = [0.0, 0.0, 0.0] + consecutive = 0 + while True: + if _should_exit(cancel_check, clock, started_at, timeout_sec): + return False + + axes = _acceleration(qmi8658.Read_XYZ()) + for index in range(3): + delta = axes[index] - baseline[index] + filtered[index] += filter_alpha * (delta - filtered[index]) + + magnitude = math.sqrt( + (filtered[0] * filtered[0]) + + (filtered[1] * filtered[1]) + + (filtered[2] * filtered[2]) + ) + if magnitude >= threshold: + consecutive += 1 + if consecutive >= trigger_samples: + return True + else: + consecutive = 0 + + _sleep_ms(clock, sample_interval_ms) diff --git a/main.py b/main.py index b018484..2815d6a 100644 --- a/main.py +++ b/main.py @@ -98,11 +98,23 @@ def main(): time.sleep(0.5) if sensitivity > 0: - touch.GoScreen(lcd, text="lights!") + touch.GoScreen( + lcd, + text="lights!", + subtitle="Double tap to cancel", + ) else: touch.GoScreen(lcd) - accel_launch(qmi8658, sensitivity=sensitivity) + launch_detected = accel_launch( + qmi8658, + sensitivity=sensitivity, + cancel_check=lambda: touch.StopGesture(lcd), + ) + if not launch_detected: + print("Launch mode cancelled or timed out.") + continue + track_session.start_session() while track_session.live is True: diff --git a/tests/test_launch.py b/tests/test_launch.py index 41c9bb7..dc599b1 100644 --- a/tests/test_launch.py +++ b/tests/test_launch.py @@ -13,20 +13,144 @@ def Read_XYZ(self): return next(self._samples) +class FakeClock: + def __init__(self): + self.milliseconds = 0 + + def ticks_ms(self): + return self.milliseconds + + def ticks_diff(self, current, previous): + return current - previous + + def sleep_ms(self, milliseconds): + self.milliseconds += milliseconds + + +def sample(x, y, z): + return [x, y, z, 0, 0, 0] + + class LaunchLoopTests(unittest.TestCase): - def test_zero_sensitivity_bypasses_sensor_reads(self): + def run_detector(self, samples, sensitivity=0.5, **overrides): + sensor = FakeAccelerometer(samples) + clock = FakeClock() + options = { + "clock": clock, + "calibration_samples": 4, + "sample_interval_ms": 20, + "filter_alpha": 0.5, + "trigger_samples": 3, + "timeout_sec": 1, + } + options.update(overrides) + result = accel_launch(sensor, sensitivity=sensitivity, **options) + return result, sensor, clock + + def test_zero_sensitivity_bypasses_sensor_and_cancel_check(self): sensor = FakeAccelerometer([]) - self.assertTrue(accel_launch(sensor, sensitivity=0)) + + self.assertTrue( + accel_launch(sensor, sensitivity=0, cancel_check=lambda: True) + ) + self.assertEqual(0, sensor.read_count) - def test_existing_all_axis_condition_is_deterministic(self): - # The threshold algorithm is intentionally unchanged here; see issue #3. - sensor = FakeAccelerometer([ - [2, 0, 0, 0, 0, 0], - [2, 2, 2, 0, 0, 0], - ]) - self.assertTrue(accel_launch(sensor, sensitivity=1)) - self.assertEqual(2, sensor.read_count) + def test_stationary_gravity_and_noise_do_not_trigger(self): + baseline = [sample(0.02, -0.06, -1.04)] * 4 + stationary = [ + sample(0.01, -0.05, -1.03), + sample(0.03, -0.07, -1.05), + ] * 20 + + result, sensor, _ = self.run_detector( + baseline + stationary, + timeout_sec=0.3, + ) + + self.assertFalse(result) + self.assertGreater(sensor.read_count, 4) + + def test_sustained_forward_axis_delta_triggers_after_debounce(self): + baseline = [sample(0.2, -0.3, -0.9)] * 4 + launch = [sample(1.7, -0.3, -0.9)] * 4 + + result, sensor, _ = self.run_detector(baseline + launch) + + self.assertTrue(result) + self.assertEqual(7, sensor.read_count) + + def test_sustained_reverse_axis_delta_also_triggers(self): + baseline = [sample(0, 0, 1)] * 4 + reverse_launch = [sample(-1.5, 0, 1)] * 4 + + result, _, _ = self.run_detector(baseline + reverse_launch) + + self.assertTrue(result) + + def test_isolated_vibration_spikes_do_not_trigger(self): + baseline = [sample(0, 0, 1)] * 4 + vibration_pattern = [ + sample(2, 0, 1), + sample(0, 0, 1), + sample(0, 0, 1), + sample(0, 0, 1), + sample(-2, 0, 1), + sample(0, 0, 1), + sample(0, 0, 1), + sample(0, 0, 1), + ] + + result, _, _ = self.run_detector( + baseline + (vibration_pattern * 4), + timeout_sec=0.4, + filter_alpha=0.35, + ) + + self.assertFalse(result) + + def test_cancel_check_exits_safely(self): + sensor = FakeAccelerometer([sample(0, 0, 1)] * 20) + clock = FakeClock() + checks = {"count": 0} + + def cancel(): + checks["count"] += 1 + return checks["count"] == 7 + + result = accel_launch( + sensor, + sensitivity=0.5, + cancel_check=cancel, + clock=clock, + calibration_samples=4, + sample_interval_ms=20, + ) + + self.assertFalse(result) + self.assertEqual(6, sensor.read_count) + + def test_timeout_exits_safely(self): + samples = [sample(0, 0, 1)] * 20 + + result, _, clock = self.run_detector(samples, timeout_sec=0.2) + + self.assertFalse(result) + self.assertEqual(200, clock.milliseconds) + + def test_invalid_filter_configuration_is_rejected(self): + sensor = FakeAccelerometer([]) + invalid_options = ( + {"calibration_samples": 0}, + {"trigger_samples": 0}, + {"sample_interval_ms": -1}, + {"filter_alpha": 0}, + {"filter_alpha": 1.1}, + ) + for options in invalid_options: + with self.subTest(options=options): + with self.assertRaises(ValueError): + accel_launch(sensor, sensitivity=0.5, **options) if __name__ == "__main__": diff --git a/touch_drive.py b/touch_drive.py index fdd83bd..0ce419b 100644 --- a/touch_drive.py +++ b/touch_drive.py @@ -199,11 +199,13 @@ def ControlScreen(self, LCD, text_array=None, back_colour=None): LCD.show() - def GoScreen(self, LCD, text='..GO!'): + def GoScreen(self, LCD, text='..GO!', subtitle=None): #self.mode = 0 #self.Set_Mode(self.Mode) LCD.fill(LCD.green) LCD.write_centered(text,92,4,LCD.white) + if subtitle is not None: + LCD.write_centered(subtitle,170,1,LCD.black) LCD.show() time.sleep(1)