diff --git a/README.md b/README.md index 14b9fcb..b05965a 100644 --- a/README.md +++ b/README.md @@ -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. @@ -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. @@ -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 ``` @@ -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 diff --git a/User Guide.md b/User Guide.md index b4c00cb..0545c33 100644 --- a/User Guide.md +++ b/User Guide.md @@ -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 diff --git a/g_force.py b/g_force.py index fef06c2..e160402 100644 --- a/g_force.py +++ b/g_force.py @@ -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) @@ -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, @@ -55,19 +75,26 @@ 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) @@ -75,44 +102,83 @@ def update(self, sample): 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: diff --git a/launch.py b/launch.py index f3a4167..d29b1a4 100644 --- a/launch.py +++ b/launch.py @@ -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. @@ -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]) diff --git a/session_summary.py b/session_summary.py new file mode 100644 index 0000000..2a7b31d --- /dev/null +++ b/session_summary.py @@ -0,0 +1,184 @@ +"""Compact, non-persistent summary shown after each track session.""" + +import gc + +from timing import secs_to_mins_secs + + +COMPLETION_DRIVER_STOP = "Driver stop" +MAX_VISIBLE_G = 99.99 +REVIEW_PAGE_COUNT = 8 + + +def _visible_g(value): + return min(MAX_VISIBLE_G, max(0.0, float(value))) + + +class SessionSummary: + """Bounded values needed for one immediate post-session display.""" + + def __init__( + self, + actual_seconds, + scheduled_seconds, + completion_reason, + maximum_g=None, + acceleration_g=None, + braking_g=None, + left_g=None, + right_g=None, + imu_complete=True, + ): + self.actual_seconds = max(0, int(actual_seconds)) + self.scheduled_seconds = max(0, int(scheduled_seconds)) + self.overrun_seconds = max( + 0, + self.actual_seconds - self.scheduled_seconds, + ) + self.completion_reason = str(completion_reason) + self.maximum_g = maximum_g + self.acceleration_g = acceleration_g + self.braking_g = braking_g + self.left_g = left_g + self.right_g = right_g + self.imu_complete = bool(imu_complete) + + @property + def has_g_metrics(self): + return self.maximum_g is not None + + +def build_session_summary( + session, + stopped_at, + g_peak=None, + completion_reason=COMPLETION_DRIVER_STOP, +): + """Capture one summary from bounded in-memory timer and IMU state.""" + actual_seconds = max(0, int(stopped_at - session.start_time)) + if g_peak is None or not g_peak.metrics_available: + return SessionSummary( + actual_seconds, + session.duration_secs, + completion_reason, + imu_complete=False, + ) + + return SessionSummary( + actual_seconds, + session.duration_secs, + completion_reason, + maximum_g=_visible_g(g_peak.total_peak_magnitude), + acceleration_g=_visible_g(g_peak.max_acceleration_g), + braking_g=_visible_g(g_peak.max_braking_g), + left_g=_visible_g(g_peak.max_left_g), + right_g=_visible_g(g_peak.max_right_g), + imu_complete=g_peak.available, + ) + + +def _review_page_data(summary, page_index): + if page_index == 0: + return ( + "SESSION TIME", + secs_to_mins_secs(summary.actual_seconds), + None, + 6, + ) + if page_index == 1: + status = "ON TIME" if summary.overrun_seconds == 0 else None + return ( + "OVERRUN", + secs_to_mins_secs(summary.overrun_seconds), + status, + 6, + ) + + metric_pages = ( + ("MAXIMUM G", summary.maximum_g), + ("ACCELERATION", summary.acceleration_g), + ("BRAKING", summary.braking_g), + ("LEFT G", summary.left_g), + ("RIGHT G", summary.right_g), + ) + if page_index < REVIEW_PAGE_COUNT - 1: + title, value = metric_pages[page_index - 2] + if not summary.has_g_metrics: + return title, "--", "IMU UNAVAILABLE", 6 + status = None if summary.imu_complete else "IMU DATA PARTIAL" + return title, "{:.2f} g".format(value), status, 6 + + return "SESSION END", summary.completion_reason.upper(), None, 3 + + +def summary_page_lines(summary, page_index): + """Return one high-visibility, round-screen-safe review page.""" + if page_index < 0 or page_index >= REVIEW_PAGE_COUNT: + raise ValueError("Summary page is out of range") + + title, value, status, value_size = _review_page_data(summary, page_index) + lines = [ + [ + "REVIEW {}/{}".format(page_index + 1, REVIEW_PAGE_COUNT), + None, + 18, + 1, + "white", + ], + [title, None, 44, 2, "green"], + [ + value, + None, + 105 if value_size == 3 else 90, + value_size, + "white", + ], + ] + if status is not None: + lines.append([status, None, 164, 1, "red"]) + + left_prompt = ( + "LEFT: cool down" + if page_index == REVIEW_PAGE_COUNT - 1 + else "LEFT: next" + ) + lines.append([left_prompt, None, 181, 1, "green"]) + lines.append( + [ + "RIGHT: back" if page_index > 0 else "Review each result", + None, + 205, + 1, + "white", + ] + ) + return lines + + +def draw_summary_page(touch, lcd, summary, page_index): + """Render one review page without retaining or writing history.""" + # Each native-font page creates short-lived line and glyph objects. Reclaim + # the previous page before building the next one to protect the RP2040 heap. + gc.collect() + touch.ControlScreen( + lcd, + text_array=summary_page_lines(summary, page_index), + back_colour="black", + ) + + +def review_session_summary(touch, lcd, summary): + """Require ordered review before allowing the cool-down flow to begin.""" + page_index = 0 + while True: + draw_summary_page(touch, lcd, summary, page_index) + while True: + gesture = touch.GetGesture(lcd, debounce_time=0.05) + if gesture == "left": + if page_index == REVIEW_PAGE_COUNT - 1: + return + page_index += 1 + break + if gesture == "right" and page_index > 0: + page_index -= 1 + break diff --git a/tests/test_g_force.py b/tests/test_g_force.py index 2839554..2d7bec2 100644 --- a/tests/test_g_force.py +++ b/tests/test_g_force.py @@ -1,7 +1,12 @@ import math import unittest -from g_force import MAX_G_UNAVAILABLE, PlanarGState, SessionGPeak +from g_force import ( + MAX_G_UNAVAILABLE, + PlanarGState, + SessionGPeak, + screen_horizontal_g, +) class FakeSensor: @@ -72,8 +77,49 @@ def test_peak_magnitude_is_planar_not_total_gravity(self): state.update((0.0, 0.0, 1.0)) self.assertEqual(0.0, state.peak_magnitude) + self.assertEqual(1.0, state.total_peak_magnitude) self.assertFalse(math.isnan(state.peak_magnitude)) + def test_session_retains_total_and_directional_peaks(self): + sensor = FakeSensor( + [ + (2.0, 0.0, 3.0), + (-4.0, 0.0, -5.0), + ] + ) + peak = SessionGPeak(sensor) + peak.state.filter_alpha = 1 + + peak.sample(rotation=0) + peak.sample(rotation=0) + + self.assertAlmostEqual(math.sqrt(41), peak.total_peak_magnitude) + self.assertEqual(3.0, peak.max_acceleration_g) + self.assertEqual(5.0, peak.max_braking_g) + self.assertEqual(4.0, peak.max_left_g) + self.assertEqual(2.0, peak.max_right_g) + self.assertTrue(peak.metrics_available) + + def test_directional_peaks_survive_late_sensor_failure(self): + peak = SessionGPeak(FakeSensor([(1.0, 0.0, 2.0)])) + peak.state.filter_alpha = 1 + peak.sample() + + peak.disable() + + self.assertFalse(peak.available) + self.assertTrue(peak.metrics_available) + self.assertEqual(2.0, peak.max_acceleration_g) + self.assertAlmostEqual(math.sqrt(5), peak.total_peak_magnitude) + + def test_screen_horizontal_axis_follows_mount_rotation(self): + axes = (2.0, 3.0, 0.0) + + self.assertEqual(2.0, screen_horizontal_g(axes, 0)) + self.assertEqual(-3.0, screen_horizontal_g(axes, 90)) + self.assertEqual(-2.0, screen_horizontal_g(axes, 180)) + self.assertEqual(3.0, screen_horizontal_g(axes, 270)) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_launch.py b/tests/test_launch.py index 66401e9..65dc7f0 100644 --- a/tests/test_launch.py +++ b/tests/test_launch.py @@ -100,6 +100,22 @@ def test_precalibrated_baseline_is_reused_without_extra_sensor_reads(self): self.assertTrue(result) self.assertEqual(3, sensor.read_count) + def test_launch_samples_can_feed_session_metrics_without_extra_reads(self): + launch = [sample(0, 0, 1.5)] * 3 + observed = [] + + result, sensor, _ = self.run_detector( + launch, + baseline=(0, 0, 0), + filter_alpha=1, + sample_update=observed.append, + ) + + self.assertTrue(result) + self.assertEqual(3, sensor.read_count) + self.assertEqual(3, len(observed)) + self.assertEqual((0.0, 0.0, 1.5), observed[-1]) + def test_isolated_vibration_spikes_do_not_trigger(self): baseline = [sample(0, 0, 1)] * 4 vibration_pattern = [ diff --git a/tests/test_session_summary.py b/tests/test_session_summary.py new file mode 100644 index 0000000..84b97ea --- /dev/null +++ b/tests/test_session_summary.py @@ -0,0 +1,208 @@ +import unittest + +from font_renderer import measure_text, pixel_height +from session_summary import ( + COMPLETION_DRIVER_STOP, + REVIEW_PAGE_COUNT, + build_session_summary, + draw_summary_page, + review_session_summary, + summary_page_lines, +) + + +class FakeSession: + start_time = 100 + duration_secs = 120 + + +class FakeGPeak: + metrics_available = True + available = True + total_peak_magnitude = 1.75 + max_acceleration_g = 0.81 + max_braking_g = 1.12 + max_left_g = 1.03 + max_right_g = 0.94 + + +class FakeTouch: + def __init__(self, gestures=None): + self.calls = [] + self.gestures = iter(gestures or []) + + def ControlScreen(self, lcd, text_array=None, back_colour=None): + self.calls.append((lcd, text_array, back_colour)) + + def GetGesture(self, lcd, debounce_time=0.05): + return next(self.gestures) + + +class SessionSummaryTests(unittest.TestCase): + def test_summary_records_actual_time_overrun_reason_and_g_peaks(self): + summary = build_session_summary( + FakeSession(), + stopped_at=225.9, + g_peak=FakeGPeak(), + ) + + self.assertEqual(125, summary.actual_seconds) + self.assertEqual(5, summary.overrun_seconds) + self.assertEqual(COMPLETION_DRIVER_STOP, summary.completion_reason) + self.assertEqual(1.75, summary.maximum_g) + self.assertEqual(0.81, summary.acceleration_g) + self.assertEqual(1.12, summary.braking_g) + self.assertEqual(1.03, summary.left_g) + self.assertEqual(0.94, summary.right_g) + + def test_on_time_summary_and_missing_imu_are_explicit(self): + summary = build_session_summary( + FakeSession(), + stopped_at=160, + g_peak=None, + ) + overrun_labels = [line[0] for line in summary_page_lines(summary, 1)] + maximum_labels = [line[0] for line in summary_page_lines(summary, 2)] + + self.assertEqual(60, summary.actual_seconds) + self.assertEqual(0, summary.overrun_seconds) + self.assertFalse(summary.has_g_metrics) + self.assertIn("ON TIME", overrun_labels) + self.assertIn("--", maximum_labels) + self.assertIn("IMU UNAVAILABLE", maximum_labels) + + def test_late_imu_failure_marks_retained_values_as_partial(self): + peak = FakeGPeak() + peak.available = False + + summary = build_session_summary(FakeSession(), 225, peak) + labels = [line[0] for line in summary_page_lines(summary, 2)] + + self.assertTrue(summary.has_g_metrics) + self.assertFalse(summary.imu_complete) + self.assertIn("IMU DATA PARTIAL", labels) + + def test_each_data_element_has_its_own_review_page(self): + summary = build_session_summary(FakeSession(), 225, FakeGPeak()) + titles = [] + values = [] + + for page_index in range(REVIEW_PAGE_COUNT): + lines = summary_page_lines(summary, page_index) + titles.append(lines[1][0]) + values.append(lines[2][0]) + + self.assertEqual( + [ + "SESSION TIME", + "OVERRUN", + "MAXIMUM G", + "ACCELERATION", + "BRAKING", + "LEFT G", + "RIGHT G", + "SESSION END", + ], + titles, + ) + self.assertEqual( + [ + "02:05", + "00:05", + "1.75 g", + "0.81 g", + "1.12 g", + "1.03 g", + "0.94 g", + "DRIVER STOP", + ], + values, + ) + + def test_every_review_page_fits_the_round_display(self): + summaries = ( + build_session_summary(FakeSession(), 225, FakeGPeak()), + build_session_summary(FakeSession(), 160, None), + ) + for summary in summaries: + for page_index in range(REVIEW_PAGE_COUNT): + for text, _x, y_position, size, _colour in summary_page_lines( + summary, + page_index, + ): + text_width = measure_text(text, size) + text_height = pixel_height(size) + for edge_y in (y_position, y_position + text_height - 1): + distance_from_center = edge_y - 120 + visible_width = 2 * ( + (120 ** 2) - (distance_from_center ** 2) + ) ** 0.5 + self.assertLessEqual(text_width, visible_width, text) + + def test_draw_uses_one_high_visibility_control_screen(self): + touch = FakeTouch() + lcd = object() + summary = build_session_summary(FakeSession(), 225, FakeGPeak()) + + draw_summary_page(touch, lcd, summary, 3) + + self.assertEqual(1, len(touch.calls)) + self.assertIs(lcd, touch.calls[0][0]) + self.assertEqual("black", touch.calls[0][2]) + self.assertEqual("ACCELERATION", touch.calls[0][1][1][0]) + self.assertEqual(6, touch.calls[0][1][2][3]) + + def test_left_advances_right_returns_and_final_left_exits(self): + gestures = ( + "left", + "left", + "right", + "left", + "left", + "left", + "left", + "left", + "left", + "left", + ) + touch = FakeTouch(gestures) + summary = build_session_summary(FakeSession(), 225, FakeGPeak()) + + review_session_summary(touch, object(), summary) + + self.assertEqual( + [ + "REVIEW 1/8", + "REVIEW 2/8", + "REVIEW 3/8", + "REVIEW 2/8", + "REVIEW 3/8", + "REVIEW 4/8", + "REVIEW 5/8", + "REVIEW 6/8", + "REVIEW 7/8", + "REVIEW 8/8", + ], + [call[1][0][0] for call in touch.calls], + ) + + def test_right_on_first_page_cannot_skip_review(self): + touch = FakeTouch(("right",) + (("left",) * REVIEW_PAGE_COUNT)) + summary = build_session_summary(FakeSession(), 225, FakeGPeak()) + + review_session_summary(touch, object(), summary) + + self.assertEqual(REVIEW_PAGE_COUNT, len(touch.calls)) + self.assertEqual("REVIEW 1/8", touch.calls[0][1][0][0]) + self.assertEqual("REVIEW 8/8", touch.calls[-1][1][0][0]) + + def test_page_index_must_be_in_range(self): + summary = build_session_summary(FakeSession(), 225, FakeGPeak()) + for page_index in (-1, REVIEW_PAGE_COUNT): + with self.subTest(page_index=page_index): + with self.assertRaises(ValueError): + summary_page_lines(summary, page_index) + + +if __name__ == "__main__": + unittest.main() diff --git a/timer_mode.py b/timer_mode.py index 4521210..dc2bc6c 100644 --- a/timer_mode.py +++ b/timer_mode.py @@ -15,6 +15,7 @@ track_live_frame, ) from ready_screen import draw_ready_screen +from session_summary import build_session_summary, review_session_summary from settings import persist_setting from timing import SessionTracker @@ -192,6 +193,10 @@ def run_timer_mode( sensitivity=sensitivity, cancel_check=lambda: touch.StopGesture(lcd), baseline=session_baseline, + sample_update=lambda sample: session_g_peak.update( + sample, + rotation=getattr(lcd, "rotation", 0), + ), ) except PeripheralError as error: qmi8658 = None @@ -207,7 +212,7 @@ def run_timer_mode( def sample_session_g(_now): nonlocal qmi8658 try: - session_g_peak.sample() + session_g_peak.sample(rotation=getattr(lcd, "rotation", 0)) except PeripheralError as error: print("Session maximum G paused: {}".format(error)) session_g_peak.disable() @@ -231,6 +236,13 @@ def sample_session_g(_now): sample_update=sample_session_g, ) + session_summary = build_session_summary( + track_session, + time.time(), + g_peak=session_g_peak, + ) + review_session_summary(touch, lcd, session_summary) + touch.ControlScreen( lcd, text_array=[PLINE1, PLINE2],