Skip to content

Commit 2265063

Browse files
Merge pull request #71 from ctrmint/feature/issue-39-post-session-summary
Add interactive post-session review
2 parents 3927af4 + 711bcd2 commit 2265063

9 files changed

Lines changed: 565 additions & 19 deletions

File tree

README.md

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,15 @@
22
Trackday or race session timer.
33

44
# Change log
5+
## Unreleased
6+
* 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.
7+
* Added left/right review navigation and prevented cool-down from starting until every result page has been reached and the final page is advanced.
8+
* Kept summary data in bounded RAM only; no session history is written to flash in this increment.
9+
* Retained captured peaks as explicitly partial data if the IMU fails during a session, while normal timing continues.
10+
* Expanded hardware-independent regression coverage to 165 tests.
11+
512
## Version 4.2
6-
### v4.2.0 [current]
13+
### v4.2.0
714
* Added continuous automatic orientation using the onboard IMU, with display rendering and touch gestures rotating together.
815
* Kept Auto rotation safe during active timing and retained normal Timer operation if the IMU is unavailable.
916
* Replaced abrupt track-session warning backgrounds with a smooth, duration-proportional green, yellow, amber, and red gradient.
@@ -101,7 +108,7 @@ python tools/convert_splash.py assets/startup_splash.gif startup_splash.rgb565 \
101108

102109
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.
103110

104-
* **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.
111+
* **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.
105112
* **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.
106113
* **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.
107114

@@ -180,7 +187,7 @@ The second command should identify an RP2040 MicroPython board.
180187
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.
181188

182189
```sh
183-
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 :
190+
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 :
184191
mpremote connect auto fs cp main.py :
185192
mpremote connect auto reset
186193
```
@@ -221,6 +228,8 @@ Version 4.2.0 uses two separate configuration scopes:
221228

222229
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.
223230

231+
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.
232+
224233
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.
225234

226235
## Host-side tests

User Guide.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,9 @@ The following describes general operation of both the ``Track Session`` and ``Re
1212
* 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.
1313
* Timer text automatically uses whichever of black or white has the greater contrast against the current background colour.
1414
* 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.
15-
* Following termination, a ``Rest in Pits`` splash will display, followed by commencement of the ``Rest in Pits Session`` timer.
15+
* 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.
16+
* 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.
17+
* After the summary, a ``Rest in Pits`` splash will display, followed by commencement of the ``Rest in Pits Session`` timer.
1618
* 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``.
1719

1820
## Configuration / Setup

g_force.py

Lines changed: 79 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,19 @@
33
import math
44
import time
55

6+
from orientation import validate_rotation
7+
68

79
FILTER_ALPHA = 0.60
810
CALIBRATION_SAMPLES = 20
911
CALIBRATION_INTERVAL_MS = 20
1012
MAX_G_UNAVAILABLE = "MAX --"
1113

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

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

2633

34+
def screen_horizontal_g(axes, rotation=0):
35+
"""Return acceleration towards the viewer's right at a mount rotation."""
36+
x_axis, y_axis, _z_axis = _axes(axes)
37+
rotation = validate_rotation(rotation)
38+
if rotation == 0:
39+
return x_axis
40+
if rotation == 90:
41+
return -y_axis
42+
if rotation == 180:
43+
return -x_axis
44+
return y_axis
45+
46+
2747
def calibrate_baseline(
2848
sensor,
2949
samples=CALIBRATION_SAMPLES,
@@ -55,64 +75,110 @@ def __init__(self, baseline=(0.0, 0.0, 0.0), filter_alpha=FILTER_ALPHA):
5575
raise ValueError("Baseline must contain x, y, and z axes")
5676
self.filter_alpha = alpha
5777
self.current = (0.0, 0.0)
78+
self.current_axes = (0.0, 0.0, 0.0)
5879
self.peak = (0.0, 0.0)
5980
self.peak_magnitude = 0.0
81+
self.total_peak_magnitude = 0.0
6082

6183
def update(self, sample):
62-
x_axis, y_axis, _z_axis = _axes(sample)
84+
x_axis, y_axis, z_axis = _axes(sample)
6385
target_x = x_axis - self.baseline[0]
6486
target_y = y_axis - self.baseline[1]
65-
filtered_x = self.current[0] + self.filter_alpha * (
66-
target_x - self.current[0]
87+
target_z = z_axis - self.baseline[2]
88+
filtered_x = self.current_axes[0] + self.filter_alpha * (
89+
target_x - self.current_axes[0]
90+
)
91+
filtered_y = self.current_axes[1] + self.filter_alpha * (
92+
target_y - self.current_axes[1]
6793
)
68-
filtered_y = self.current[1] + self.filter_alpha * (
69-
target_y - self.current[1]
94+
filtered_z = self.current_axes[2] + self.filter_alpha * (
95+
target_z - self.current_axes[2]
7096
)
97+
self.current_axes = (filtered_x, filtered_y, filtered_z)
7198
self.current = (filtered_x, filtered_y)
7299
magnitude = math.sqrt(
73100
(filtered_x * filtered_x) + (filtered_y * filtered_y)
74101
)
75102
if magnitude > self.peak_magnitude:
76103
self.peak_magnitude = magnitude
77104
self.peak = self.current
105+
total_magnitude = math.sqrt(
106+
(filtered_x * filtered_x)
107+
+ (filtered_y * filtered_y)
108+
+ (filtered_z * filtered_z)
109+
)
110+
if total_magnitude > self.total_peak_magnitude:
111+
self.total_peak_magnitude = total_magnitude
78112
return self.current
79113

80114
def reset_peak(self):
81115
self.peak = (0.0, 0.0)
82116
self.peak_magnitude = 0.0
117+
self.total_peak_magnitude = 0.0
83118

84119

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

88123
def __init__(self, sensor=None, baseline=(0.0, 0.0, 0.0)):
89124
self.sensor = sensor
90125
self.state = PlanarGState(baseline=baseline) if sensor is not None else None
126+
self.sample_count = 0
127+
self.max_acceleration_g = 0.0
128+
self.max_braking_g = 0.0
129+
self.max_left_g = 0.0
130+
self.max_right_g = 0.0
91131
self._display_second = None
92132
self._display_label = MAX_G_UNAVAILABLE
93133

94134
@property
95135
def available(self):
96-
return self.state is not None
136+
return self.sensor is not None and self.state is not None
137+
138+
@property
139+
def metrics_available(self):
140+
return self.state is not None and self.sample_count > 0
97141

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

102-
def sample(self):
103-
"""Take one sample; the caller controls the bounded polling rate."""
104-
if self.state is not None:
105-
self.state.update(self.sensor.Read_XYZ())
146+
@property
147+
def total_peak_magnitude(self):
148+
return 0.0 if self.state is None else self.state.total_peak_magnitude
149+
150+
def update(self, sample, rotation=0):
151+
"""Update peaks from one already-read sample."""
152+
if not self.available:
153+
return
154+
self.state.update(sample)
155+
self.sample_count += 1
156+
157+
lateral_g = screen_horizontal_g(self.state.current_axes, rotation)
158+
longitudinal_g = (
159+
self.state.current_axes[2] * ACCELERATION_AXIS_SIGN
160+
)
161+
self.max_right_g = max(self.max_right_g, lateral_g)
162+
self.max_left_g = max(self.max_left_g, -lateral_g)
163+
self.max_acceleration_g = max(
164+
self.max_acceleration_g,
165+
longitudinal_g,
166+
)
167+
self.max_braking_g = max(self.max_braking_g, -longitudinal_g)
168+
169+
def sample(self, rotation=0):
170+
"""Read one sample; the caller controls the bounded polling rate."""
171+
if self.available:
172+
self.update(self.sensor.Read_XYZ(), rotation=rotation)
106173

107174
def disable(self):
108175
self.sensor = None
109-
self.state = None
110176
self._display_second = None
111177
self._display_label = MAX_G_UNAVAILABLE
112178

113179
def display_label(self, elapsed_seconds):
114180
"""Return a stable label so peak sampling does not add redraws."""
115-
if self.state is None:
181+
if not self.available:
116182
return MAX_G_UNAVAILABLE
117183
visible_second = max(0, int(elapsed_seconds))
118184
if visible_second != self._display_second:

launch.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ def accel_launch(
6767
filter_alpha=FILTER_ALPHA,
6868
trigger_samples=TRIGGER_SAMPLES,
6969
baseline=None,
70+
sample_update=None,
7071
):
7172
"""Wait for a sustained acceleration-vector change and return its outcome.
7273
@@ -112,6 +113,8 @@ def accel_launch(
112113
return False
113114

114115
axes = _acceleration(qmi8658.Read_XYZ())
116+
if sample_update is not None:
117+
sample_update(axes)
115118
for index in range(3):
116119
delta = axes[index] - baseline[index]
117120
filtered[index] += filter_alpha * (delta - filtered[index])

0 commit comments

Comments
 (0)