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
15 changes: 12 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,15 @@
Trackday or race session timer.

# Change log
## Unreleased
* Added an interactive post-track review with a high-visibility page for actual duration, overrun, completion reason, total maximum G, and each directional acceleration/braking/left/right peak.
* Added left/right review navigation and prevented cool-down from starting until every result page has been reached and the final page is advanced.
* Kept summary data in bounded RAM only; no session history is written to flash in this increment.
* Retained captured peaks as explicitly partial data if the IMU fails during a session, while normal timing continues.
* Expanded hardware-independent regression coverage to 165 tests.

## Version 4.2
### v4.2.0 [current]
### v4.2.0
* Added continuous automatic orientation using the onboard IMU, with display rendering and touch gestures rotating together.
* Kept Auto rotation safe during active timing and retained normal Timer operation if the IMU is unavailable.
* Replaced abrupt track-session warning backgrounds with a smooth, duration-proportional green, yellow, amber, and red gradient.
Expand Down Expand Up @@ -101,7 +108,7 @@ python tools/convert_splash.py assets/startup_splash.gif startup_splash.rgb565 \

Press and continuously hold the touchscreen for five seconds from the Timer Ready screen or G Mode to open the operating-mode menu. Releasing early cancels the hold. For safety, the menu cannot interrupt a running track/rest session or the Launch Mode wait. In menus, swipe left/right to choose, swipe up to select, and swipe down to cancel. The selected operating mode persists across restarts.

* **Timer Mode** retains the existing track, rest, and Launch Mode workflow. During a track session, a baseline-corrected value such as `MAX 1.23 g` appears in a compact, clearly spaced line above the countdown. The peak resets for each track session and remains visible through overrun. `MAX --` indicates that acceleration data is unavailable; timing and the stop gesture continue normally. Rest sessions do not show maximum G.
* **Timer Mode** retains the existing track, rest, and Launch Mode workflow. During a track session, a baseline-corrected value such as `MAX 1.23 g` appears in a compact, clearly spaced line above the countdown. The peak resets for each track session and remains visible through overrun. `MAX --` indicates that acceleration data is unavailable; timing and the stop gesture continue normally. After a double-tap stop, an interactive review gives actual duration, overrun, total maximum G, acceleration, braking, left G, right G, and the completion reason their own high-visibility screens. Swipe left to advance and right to go back; cool-down begins only after swiping left from the eighth and final page. The review is held in RAM only and is not retained after leaving it. Rest sessions do not show maximum G.
* **G Mode** calibrates the stationary QMI8658 baseline, then presents a responsive graphical round G meter rather than numeric telemetry. The green filled marker and short trail show the current filtered acceleration vector at the LCD's display-limited refresh rate. The red hollow marker records the maximum vector, while the red perimeter arc shows peak magnitude relative to the 4 g visual scale. Double-tap resets the trail and peak. Hold for five seconds to return to the mode menu.
* **Settings** provides 25%, 50%, 75%, and 100% brightness choices with immediate preview. Rotation offers **Auto** plus fixed 0°, 90°, 180°, and 270° clockwise mounting angles. Auto uses the onboard IMU to keep the display upright as the device turns; fixed choices continue to work without the IMU. In every case, touch gestures remain relative to the text on screen. Swipe up saves a preview; swipe down cancels and restores the previous brightness or orientation. **Restore defaults** requires confirmation, then restores Timer Mode, 100% brightness, fixed 0° rotation, 20-minute track/rest sessions, and disabled Launch Mode.

Expand Down Expand Up @@ -180,7 +187,7 @@ The second command should identify an RP2040 MicroPython board.
Run these commands from the repository root. Supporting files and font assets are copied first; `main.py` is installed last as the automatic entry point.

```sh
mpremote connect auto fs cp application.py auto_rotation.py battery.py configuration.py font_data.py font_renderer.py g_force.py g_meter.py hardware.py hardware_splash.py hold_detector.py launch.py lcd_1inch28.py live_display.py operating_modes.py orientation.py params.json qmi8658.py ready_screen.py settings.py splash.py timer_mode.py timing.py touch_drive.py font_data*.bin startup_splash.rgb565 :
mpremote connect auto fs cp application.py auto_rotation.py battery.py configuration.py font_data.py font_renderer.py g_force.py g_meter.py hardware.py hardware_splash.py hold_detector.py launch.py lcd_1inch28.py live_display.py operating_modes.py orientation.py params.json qmi8658.py ready_screen.py session_summary.py settings.py splash.py timer_mode.py timing.py touch_drive.py font_data*.bin startup_splash.rgb565 :
mpremote connect auto fs cp main.py :
mpremote connect auto reset
```
Expand Down Expand Up @@ -221,6 +228,8 @@ Version 4.2.0 uses two separate configuration scopes:

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.

Directional summary labels use a dashboard mounting convention: the screen faces the driver, the screen-normal axis represents acceleration/braking, and the viewer-horizontal axis represents left/right. Fixed and automatic quarter-turn display rotations are applied to the lateral mapping. Mounting the board with its screen facing away from the driver reverses the longitudinal labels.

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, while older files gain Timer Mode, 100% brightness, and 0° rotation defaults.

## Host-side tests
Expand Down
4 changes: 3 additions & 1 deletion User Guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,9 @@ The following describes general operation of both the ``Track Session`` and ``Re
* The clearly spaced line such as ``MAX 1.23 g`` above the countdown shows the largest filtered planar acceleration recorded during the current track session. It is positioned with a clear gap from the countdown, resets at each new track session, and remains visible during overrun. ``MAX --`` means the IMU is unavailable; normal timing and the double-tap stop gesture continue. Rest sessions do not show this value.
* Timer text automatically uses whichever of black or white has the greater contrast against the current background colour.
* Once the ``Track Session`` has completed, i.e. >100%, the background becomes deep purple with white text and the timer remains running to provide visibility of any overrun. ``Double Tap`` to complete/exit. A ``Double Tap`` can be used to terminate any running timer.
* Following termination, a ``Rest in Pits`` splash will display, followed by commencement of the ``Rest in Pits Session`` timer.
* Following termination, an eight-page post-session review begins. Actual run time, overrun, total maximum G, maximum acceleration, maximum braking, maximum left G, maximum right G, and the stop reason each have a separate high-visibility screen. Swipe ``Left`` to advance and ``Right`` to return to the previous result. The first page cannot be bypassed backwards, and cool-down starts only after swiping ``Left`` from the eighth and final page. ``--`` and ``IMU UNAVAILABLE`` mean the session completed without usable sensor data; ``IMU DATA PARTIAL`` means peaks captured before a sensor fault were retained. The review is for immediate inspection only and is not saved to flash.
* Directional labels assume a dashboard installation with the screen upright and facing the driver. The firmware corrects left/right for the selected quarter-turn display rotation. A screen mounted facing away from the driver reverses the acceleration/braking convention.
* After the summary, a ``Rest in Pits`` splash will display, followed by commencement of the ``Rest in Pits Session`` timer.
* Once the ``Rest in Pits Session`` is complete, the timer will return to the ``Primary screen``. The ``Rest in Pits Session`` can be terminated with a ``Double Tap``.

## Configuration / Setup
Expand Down
92 changes: 79 additions & 13 deletions g_force.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,19 @@
import math
import time

from orientation import validate_rotation


FILTER_ALPHA = 0.60
CALIBRATION_SAMPLES = 20
CALIBRATION_INTERVAL_MS = 20
MAX_G_UNAVAILABLE = "MAX --"

# Dashboard mounting convention: the display faces the driver, so the IMU's
# Z axis is longitudinal. Keep the sign in one place in case a future case or
# mounting plate places the board behind the display instead.
ACCELERATION_AXIS_SIGN = 1.0


def _sleep_ms(clock, milliseconds):
sleep_ms = getattr(clock, "sleep_ms", None)
Expand All @@ -24,6 +31,19 @@ def _axes(sample):
return float(sample[0]), float(sample[1]), float(sample[2])


def screen_horizontal_g(axes, rotation=0):
"""Return acceleration towards the viewer's right at a mount rotation."""
x_axis, y_axis, _z_axis = _axes(axes)
rotation = validate_rotation(rotation)
if rotation == 0:
return x_axis
if rotation == 90:
return -y_axis
if rotation == 180:
return -x_axis
return y_axis


def calibrate_baseline(
sensor,
samples=CALIBRATION_SAMPLES,
Expand Down Expand Up @@ -55,64 +75,110 @@ def __init__(self, baseline=(0.0, 0.0, 0.0), filter_alpha=FILTER_ALPHA):
raise ValueError("Baseline must contain x, y, and z axes")
self.filter_alpha = alpha
self.current = (0.0, 0.0)
self.current_axes = (0.0, 0.0, 0.0)
self.peak = (0.0, 0.0)
self.peak_magnitude = 0.0
self.total_peak_magnitude = 0.0

def update(self, sample):
x_axis, y_axis, _z_axis = _axes(sample)
x_axis, y_axis, z_axis = _axes(sample)
target_x = x_axis - self.baseline[0]
target_y = y_axis - self.baseline[1]
filtered_x = self.current[0] + self.filter_alpha * (
target_x - self.current[0]
target_z = z_axis - self.baseline[2]
filtered_x = self.current_axes[0] + self.filter_alpha * (
target_x - self.current_axes[0]
)
filtered_y = self.current_axes[1] + self.filter_alpha * (
target_y - self.current_axes[1]
)
filtered_y = self.current[1] + self.filter_alpha * (
target_y - self.current[1]
filtered_z = self.current_axes[2] + self.filter_alpha * (
target_z - self.current_axes[2]
)
self.current_axes = (filtered_x, filtered_y, filtered_z)
self.current = (filtered_x, filtered_y)
magnitude = math.sqrt(
(filtered_x * filtered_x) + (filtered_y * filtered_y)
)
if magnitude > self.peak_magnitude:
self.peak_magnitude = magnitude
self.peak = self.current
total_magnitude = math.sqrt(
(filtered_x * filtered_x)
+ (filtered_y * filtered_y)
+ (filtered_z * filtered_z)
)
if total_magnitude > self.total_peak_magnitude:
self.total_peak_magnitude = total_magnitude
return self.current

def reset_peak(self):
self.peak = (0.0, 0.0)
self.peak_magnitude = 0.0
self.total_peak_magnitude = 0.0


class SessionGPeak:
"""Bounded session sampler with a once-per-visible-second label."""
"""Bounded session sampler with retained total and directional peaks."""

def __init__(self, sensor=None, baseline=(0.0, 0.0, 0.0)):
self.sensor = sensor
self.state = PlanarGState(baseline=baseline) if sensor is not None else None
self.sample_count = 0
self.max_acceleration_g = 0.0
self.max_braking_g = 0.0
self.max_left_g = 0.0
self.max_right_g = 0.0
self._display_second = None
self._display_label = MAX_G_UNAVAILABLE

@property
def available(self):
return self.state is not None
return self.sensor is not None and self.state is not None

@property
def metrics_available(self):
return self.state is not None and self.sample_count > 0

@property
def peak_magnitude(self):
return 0.0 if self.state is None else self.state.peak_magnitude

def sample(self):
"""Take one sample; the caller controls the bounded polling rate."""
if self.state is not None:
self.state.update(self.sensor.Read_XYZ())
@property
def total_peak_magnitude(self):
return 0.0 if self.state is None else self.state.total_peak_magnitude

def update(self, sample, rotation=0):
"""Update peaks from one already-read sample."""
if not self.available:
return
self.state.update(sample)
self.sample_count += 1

lateral_g = screen_horizontal_g(self.state.current_axes, rotation)
longitudinal_g = (
self.state.current_axes[2] * ACCELERATION_AXIS_SIGN
)
self.max_right_g = max(self.max_right_g, lateral_g)
self.max_left_g = max(self.max_left_g, -lateral_g)
self.max_acceleration_g = max(
self.max_acceleration_g,
longitudinal_g,
)
self.max_braking_g = max(self.max_braking_g, -longitudinal_g)

def sample(self, rotation=0):
"""Read one sample; the caller controls the bounded polling rate."""
if self.available:
self.update(self.sensor.Read_XYZ(), rotation=rotation)

def disable(self):
self.sensor = None
self.state = None
self._display_second = None
self._display_label = MAX_G_UNAVAILABLE

def display_label(self, elapsed_seconds):
"""Return a stable label so peak sampling does not add redraws."""
if self.state is None:
if not self.available:
return MAX_G_UNAVAILABLE
visible_second = max(0, int(elapsed_seconds))
if visible_second != self._display_second:
Expand Down
3 changes: 3 additions & 0 deletions launch.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ def accel_launch(
filter_alpha=FILTER_ALPHA,
trigger_samples=TRIGGER_SAMPLES,
baseline=None,
sample_update=None,
):
"""Wait for a sustained acceleration-vector change and return its outcome.

Expand Down Expand Up @@ -112,6 +113,8 @@ def accel_launch(
return False

axes = _acceleration(qmi8658.Read_XYZ())
if sample_update is not None:
sample_update(axes)
for index in range(3):
delta = axes[index] - baseline[index]
filtered[index] += filter_alpha * (delta - filtered[index])
Expand Down
Loading
Loading