Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
20 changes: 19 additions & 1 deletion User Guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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. |
137 changes: 123 additions & 14 deletions launch.py
Original file line number Diff line number Diff line change
@@ -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)
16 changes: 14 additions & 2 deletions main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
144 changes: 134 additions & 10 deletions tests/test_launch.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__":
Expand Down
4 changes: 3 additions & 1 deletion touch_drive.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
Loading