From 9852e5602ab2053b5770ca010e25c02a78252932 Mon Sep 17 00:00:00 2001 From: mrsqr Date: Mon, 10 Aug 2026 23:17:44 +0100 Subject: [PATCH] Add session maximum G display --- README.md | 10 +-- User Guide.md | 5 +- application.py | 5 +- g_force.py | 124 +++++++++++++++++++++++++++++++++++++ g_meter.py | 62 ++----------------- launch.py | 30 +++++---- live_display.py | 13 +++- tests/test_g_force.py | 79 +++++++++++++++++++++++ tests/test_launch.py | 12 ++++ tests/test_live_display.py | 63 +++++++++++++++++-- tests/test_touch_mode.py | 43 +++++++++++++ timer_mode.py | 54 +++++++++++++++- touch_drive.py | 24 ++++++- 13 files changed, 432 insertions(+), 92 deletions(-) create mode 100644 g_force.py create mode 100644 tests/test_g_force.py diff --git a/README.md b/README.md index d10cc46..14b9fcb 100644 --- a/README.md +++ b/README.md @@ -68,7 +68,7 @@ The timer utilizes the Waveshare 1.28-inch round touch display, allowing for con The firmware includes a compact proportional bitmap font rendered directly at the display's native resolution. It replaces enlargement of MicroPython's 8x8 framebuffer font, so large countdown digits and labels retain smooth shapes instead of scaling into square pixels. -The running track and rest countdown uses the 74-pixel native font, the closest available pre-rendered size to a 10% increase from the previous 64-pixel countdown. Timer digits use equal-width cells, so changing figures do not move the centered countdown or elapsed-time positions. +The running track and rest countdown uses the 74-pixel native font, the closest available pre-rendered size to a 10% increase from the previous 64-pixel countdown. Timer digits use equal-width cells, so changing figures do not move the centered countdown, maximum-G, or elapsed-time positions. `font_data.py` and its flash-backed `font_data*.bin` glyph assets are generated from Montserrat SemiBold. The assets contain pre-rasterized native UI sizes, allowing the Pico to use its fast framebuffer blitter without holding the complete font in RAM. To regenerate them, install Pillow and run: @@ -80,7 +80,7 @@ The generated font data is distributed under the SIL Open Font License 1.1 in `F ## Live display refresh -Track and rest sessions poll stop gestures every 50 ms while comparing the complete visible frame (remaining time, elapsed time, font size, background, and text colour) with the previous frame. Track colour is interpolated from whole elapsed seconds, making the blend proportional to the selected duration while retaining a maximum of one normal full-screen transfer per displayed second. Touch-controller mode changes are also cached, so an unchanged gesture mode does not generate repeated I2C writes. +Track and rest sessions poll stop gestures every 50 ms while comparing the complete visible frame (remaining time, elapsed time, maximum G, font size, background, and text colour) with the previous frame. Track colour is interpolated from whole elapsed seconds, making the blend proportional to the selected duration while retaining a maximum of one normal full-screen transfer per displayed second. Maximum G is sampled at that same bounded polling rate but its visible value is latched to the displayed second, so sensing does not add framebuffer transfers. Touch-controller mode changes are also cached, so an unchanged gesture mode does not generate repeated I2C writes. On the supported Waveshare board running MicroPython 1.21.0, five full live-screen redraws measured 56.2–65.4 ms. Input is therefore checked within 50 ms between redraws and within approximately 115 ms in the worst case when a gesture arrives immediately before a redraw. Five consecutive frames produced only the two register writes needed for the initial gesture-mode configuration and no rewrites on later frames. @@ -101,7 +101,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. +* **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. * **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 +180,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_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 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 ``` @@ -204,7 +204,7 @@ If first boot fails: * An import error generally means a `.py` support module was omitted; repeat the upload command and keep `main.py` last. * No serial device after flashing usually indicates a charge-only USB cable, an incorrect UF2, or a board still in BOOT mode. * A touchscreen hardware error is a controlled stop: check that this is the supported integrated board, then restart it. The serial message includes the failed operation or unexpected chip ID. -* An IMU hardware error disables Launch Mode for the current run. Swipe down to use the normal timer, which remains available without the IMU. +* An IMU hardware error disables Launch Mode and the session maximum-G reading for the current run. Swipe down to use the normal timer; `MAX --` confirms that timing remains available without the IMU. ### Peripheral failure policy diff --git a/User Guide.md b/User Guide.md index a75614c..b4c00cb 100644 --- a/User Guide.md +++ b/User Guide.md @@ -5,10 +5,11 @@ The following describes general operation of both the ``Track Session`` and ``Re * Upon startup, the Caterham boot artwork is followed by a hardware-information screen showing the board, processor, firmware, operating system, and platform. Each screen is shown for two seconds by default. Maintainers can tune the waits independently with `STARTUP_SPLASH_DURATION_SEC` and `HARDWARE_SPLASH_DURATION_SEC` in `params.json`. * The ``Primary Screen`` will then show ``Ready`` together with the saved track duration, rest duration, and effective Launch Mode state. A battery icon above `Ready` fills from left to right with estimated remaining charge. A lightning bolt through the battery means USB/external power is present. When the timer starts while connected to USB, the initial full fill represents powered status because this board cannot read the isolated battery cell until it runs from battery. ``Launch unavailable`` means the saved non-zero sensitivity could not be used because the IMU is unavailable; normal swipe-down timing still works. To start the ``Track Session`` or race, ``Swipe Down``. Hold the screen continuously for five seconds to open the operating-mode menu. -* 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. +* After swiping down, keep the device still for the brief ``SESSION G - Calibrating`` screen. This establishes the acceleration baseline for both the session peak and Launch Mode. ``Go`` will then display briefly. If ``Launch Mode`` has been activated, ``Lights`` will be displayed while the timer 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. * During a ``Track Session``, the background blends continuously from green at the start, through yellow at one-third and amber at two-thirds, towards red at scheduled expiry. The blend is proportional to the selected session length rather than using fixed times. +* 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. @@ -66,7 +67,7 @@ It is possible to change the duration of both the ``Track Session`` and the ``Re * 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. +Keep the timer stationary while ``SESSION G - Calibrating`` appears. The firmware averages 20 samples over 0.4 seconds to remove gravity and the device's mounting orientation, then reuses that baseline for Launch Mode. It 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: diff --git a/application.py b/application.py index 01ffd45..9da98d5 100644 --- a/application.py +++ b/application.py @@ -15,6 +15,7 @@ "hold_detector", "launch", "live_display", + "g_force", "ready_screen", "timing", ) @@ -265,7 +266,7 @@ def run_application(lcd): ) except PeripheralError as error: del run_g_mode - _unload_modules(("g_meter", "hold_detector")) + _unload_modules(("g_meter", "g_force", "hold_detector")) if error.peripheral == "CST816T": _show_touch_failure(lcd, error) return False @@ -278,7 +279,7 @@ def run_application(lcd): continue del run_g_mode - _unload_modules(("g_meter", "hold_detector")) + _unload_modules(("g_meter", "g_force", "hold_detector")) user_params, active_mode, qmi8658 = _open_mode_menu( touch, lcd, diff --git a/g_force.py b/g_force.py new file mode 100644 index 0000000..fef06c2 --- /dev/null +++ b/g_force.py @@ -0,0 +1,124 @@ +"""Shared baseline-corrected G calculations for live operating modes.""" + +import math +import time + + +FILTER_ALPHA = 0.60 +CALIBRATION_SAMPLES = 20 +CALIBRATION_INTERVAL_MS = 20 +MAX_G_UNAVAILABLE = "MAX --" + + +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 _axes(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 calibrate_baseline( + sensor, + samples=CALIBRATION_SAMPLES, + interval_ms=CALIBRATION_INTERVAL_MS, + clock=time, +): + """Average stationary samples so gravity/mounting bias can be removed.""" + if samples < 1: + raise ValueError("Calibration samples must be positive") + baseline = [0.0, 0.0, 0.0] + for _ in range(samples): + axes = _axes(sensor.Read_XYZ()) + baseline[0] += axes[0] + baseline[1] += axes[1] + baseline[2] += axes[2] + _sleep_ms(clock, interval_ms) + return tuple(value / samples for value in baseline) + + +class PlanarGState: + """Filtered planar acceleration and peak independent of presentation.""" + + def __init__(self, baseline=(0.0, 0.0, 0.0), filter_alpha=FILTER_ALPHA): + alpha = float(filter_alpha) + if alpha <= 0 or alpha > 1: + raise ValueError("Filter alpha must be greater than 0 and at most 1") + self.baseline = tuple(float(value) for value in baseline[:3]) + if len(self.baseline) != 3: + raise ValueError("Baseline must contain x, y, and z axes") + self.filter_alpha = alpha + self.current = (0.0, 0.0) + self.peak = (0.0, 0.0) + self.peak_magnitude = 0.0 + + def update(self, 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] + ) + filtered_y = self.current[1] + self.filter_alpha * ( + target_y - self.current[1] + ) + 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 + return self.current + + def reset_peak(self): + self.peak = (0.0, 0.0) + self.peak_magnitude = 0.0 + + +class SessionGPeak: + """Bounded session sampler with a once-per-visible-second label.""" + + 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._display_second = None + self._display_label = MAX_G_UNAVAILABLE + + @property + def available(self): + return self.state is not None + + @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()) + + 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: + return MAX_G_UNAVAILABLE + visible_second = max(0, int(elapsed_seconds)) + if visible_second != self._display_second: + # The supported IMU cannot reach 100 g. Capping protects the round + # display safe area if a corrupt sample reports an extreme value. + visible_peak = min(99.99, self.state.peak_magnitude) + self._display_label = "MAX {:.2f} g".format(visible_peak) + self._display_second = visible_second + return self._display_label diff --git a/g_meter.py b/g_meter.py index 344dda4..a88acf3 100644 --- a/g_meter.py +++ b/g_meter.py @@ -3,6 +3,7 @@ import math import time +from g_force import FILTER_ALPHA, PlanarGState, calibrate_baseline from hold_detector import HoldDetector @@ -12,10 +13,7 @@ PEAK_ARC_RADIUS = 82 PEAK_ARC_DOT_RADIUS = 2 VISUAL_SCALE_G = 4.0 -FILTER_ALPHA = 0.60 TRAIL_LENGTH = 6 -CALIBRATION_SAMPLES = 20 -CALIBRATION_INTERVAL_MS = 20 FRAME_PERIOD_MS = 60 @@ -50,31 +48,6 @@ def remaining_frame_delay_ms(clock, frame_started, period_ms=FRAME_PERIOD_MS): return max(0, int(period_ms) - elapsed) -def _axes(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 calibrate_baseline( - sensor, - samples=CALIBRATION_SAMPLES, - interval_ms=CALIBRATION_INTERVAL_MS, - clock=time, -): - """Average stationary samples so gravity/mounting bias can be removed.""" - if samples < 1: - raise ValueError("Calibration samples must be positive") - baseline = [0.0, 0.0, 0.0] - for _ in range(samples): - axes = _axes(sensor.Read_XYZ()) - baseline[0] += axes[0] - baseline[1] += axes[1] - baseline[2] += axes[2] - _sleep_ms(clock, interval_ms) - return tuple(value / samples for value in baseline) - - def vector_point( vector, scale_g=VISUAL_SCALE_G, @@ -99,7 +72,7 @@ def vector_point( ) -class GMeterState: +class GMeterState(PlanarGState): """Bounded filtered live/peak state independent of display hardware.""" def __init__( @@ -109,49 +82,24 @@ def __init__( visual_scale_g=VISUAL_SCALE_G, trail_length=TRAIL_LENGTH, ): - alpha = float(filter_alpha) - if alpha <= 0 or alpha > 1: - raise ValueError("Filter alpha must be greater than 0 and at most 1") + super().__init__(baseline=baseline, filter_alpha=filter_alpha) if float(visual_scale_g) <= 0: raise ValueError("Visual scale must be greater than zero") if trail_length < 1: raise ValueError("Trail length must be positive") - self.baseline = tuple(float(value) for value in baseline[:3]) - if len(self.baseline) != 3: - raise ValueError("Baseline must contain x, y, and z axes") - self.filter_alpha = alpha self.visual_scale_g = float(visual_scale_g) self.trail_length = int(trail_length) - self.current = (0.0, 0.0) - self.peak = (0.0, 0.0) - self.peak_magnitude = 0.0 self.trail = [] def update(self, 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] - ) - filtered_y = self.current[1] + self.filter_alpha * ( - target_y - self.current[1] - ) - 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 + super().update(sample) self.trail.append(self.current) if len(self.trail) > self.trail_length: del self.trail[0] return self.current def reset_peak(self): - self.peak = (0.0, 0.0) - self.peak_magnitude = 0.0 + super().reset_peak() self.trail = [] diff --git a/launch.py b/launch.py index 535404a..f3a4167 100644 --- a/launch.py +++ b/launch.py @@ -66,6 +66,7 @@ def accel_launch( sample_interval_ms=SAMPLE_INTERVAL_MS, filter_alpha=FILTER_ALPHA, trigger_samples=TRIGGER_SAMPLES, + baseline=None, ): """Wait for a sustained acceleration-vector change and return its outcome. @@ -87,19 +88,22 @@ def accel_launch( 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 + if baseline is None: + 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 + else: + baseline = _acceleration(baseline) filtered = [0.0, 0.0, 0.0] consecutive = 0 diff --git a/live_display.py b/live_display.py index 365b19c..477e149 100644 --- a/live_display.py +++ b/live_display.py @@ -115,7 +115,7 @@ def _visible_times(session, now): ) -def track_live_frame(session, now, lcd): +def track_live_frame(session, now, lcd, maximum_g=None): """Return the track timer with a smooth proportional colour gradient.""" remaining, elapsed = _visible_times(session, now) if now >= session.end_time: @@ -140,6 +140,7 @@ def track_live_frame(session, now, lcd): COUNTDOWN_TEXT_SIZE, background, text_colour, + maximum_g, ) @@ -154,12 +155,13 @@ def rest_live_frame(session, now, lcd): COUNTDOWN_TEXT_SIZE, lcd.blue, None, + None, ) def draw_live_frame(touch, lcd, frame): """Render one frame tuple produced by a live-frame builder.""" - remaining, elapsed, text_size, background, text_colour = frame + remaining, elapsed, text_size, background, text_colour, maximum_g = frame touch.LiveScreen( lcd, textsize_rem=text_size, @@ -167,6 +169,7 @@ def draw_live_frame(touch, lcd, frame): textColour=text_colour, elapsed=elapsed, remaining=remaining, + maximum_g=maximum_g, ) @@ -177,6 +180,7 @@ def run_live_display( stop_check, clock=time, loop_delay_sec=LIVE_LOOP_DELAY_SEC, + sample_update=None, ): """Poll input at a bounded rate and redraw only when visible state changes.""" if loop_delay_sec <= 0: @@ -191,7 +195,10 @@ def run_live_display( session.live = False break - frame = frame_builder(clock.time()) + now = clock.time() + if sample_update is not None: + sample_update(now) + frame = frame_builder(now) if frame is None: session.live = False break diff --git a/tests/test_g_force.py b/tests/test_g_force.py new file mode 100644 index 0000000..2839554 --- /dev/null +++ b/tests/test_g_force.py @@ -0,0 +1,79 @@ +import math +import unittest + +from g_force import MAX_G_UNAVAILABLE, PlanarGState, SessionGPeak + + +class FakeSensor: + def __init__(self, samples): + self.samples = iter(samples) + self.read_count = 0 + + def Read_XYZ(self): + self.read_count += 1 + return next(self.samples) + + +class GForceTests(unittest.TestCase): + def test_planar_filter_removes_baseline_and_retains_largest_peak(self): + state = PlanarGState( + baseline=(1.0, 2.0, 3.0), + filter_alpha=1, + ) + + state.update((4.0, -2.0, 99.0)) + expected_peak = 5.0 + self.assertAlmostEqual(expected_peak, state.peak_magnitude) + self.assertEqual((3.0, -4.0), state.peak) + + state.update((2.0, 2.0, -99.0)) + self.assertAlmostEqual(expected_peak, state.peak_magnitude) + self.assertEqual((3.0, -4.0), state.peak) + + def test_session_peak_samples_live_sensor_and_resets_per_instance(self): + sensor = FakeSensor([(1.0, 0.0, 0.0), (2.0, 0.0, 0.0)]) + peak = SessionGPeak(sensor) + + peak.sample() + first_peak = peak.peak_magnitude + peak.sample() + + self.assertEqual(2, sensor.read_count) + self.assertGreater(peak.peak_magnitude, first_peak) + self.assertEqual(0.0, SessionGPeak(sensor).peak_magnitude) + + def test_visible_peak_is_latched_to_timer_second(self): + sensor = FakeSensor([(1.0, 0.0, 0.0), (2.0, 0.0, 0.0)]) + peak = SessionGPeak(sensor) + + peak.sample() + first_label = peak.display_label(0.1) + peak.sample() + + self.assertEqual("MAX 0.60 g", first_label) + self.assertEqual(first_label, peak.display_label(0.9)) + self.assertEqual("MAX 1.44 g", peak.display_label(1.0)) + + def test_unavailable_or_failed_session_uses_placeholder(self): + peak = SessionGPeak() + self.assertFalse(peak.available) + self.assertEqual(MAX_G_UNAVAILABLE, peak.display_label(0)) + + active = SessionGPeak(FakeSensor([(1.0, 1.0, 0.0)])) + active.sample() + active.disable() + + self.assertFalse(active.available) + self.assertEqual(MAX_G_UNAVAILABLE, active.display_label(1)) + + def test_peak_magnitude_is_planar_not_total_gravity(self): + state = PlanarGState(filter_alpha=1) + + state.update((0.0, 0.0, 1.0)) + + self.assertEqual(0.0, state.peak_magnitude) + self.assertFalse(math.isnan(state.peak_magnitude)) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_launch.py b/tests/test_launch.py index dc599b1..66401e9 100644 --- a/tests/test_launch.py +++ b/tests/test_launch.py @@ -88,6 +88,18 @@ def test_sustained_reverse_axis_delta_also_triggers(self): self.assertTrue(result) + def test_precalibrated_baseline_is_reused_without_extra_sensor_reads(self): + launch = [sample(1.5, 0, 1)] * 3 + + result, sensor, _ = self.run_detector( + launch, + baseline=(0, 0, 1), + filter_alpha=1, + ) + + self.assertTrue(result) + self.assertEqual(3, sensor.read_count) + def test_isolated_vibration_spikes_do_not_trigger(self): baseline = [sample(0, 0, 1)] * 4 vibration_pattern = [ diff --git a/tests/test_live_display.py b/tests/test_live_display.py index 57f4a08..fc3f006 100644 --- a/tests/test_live_display.py +++ b/tests/test_live_display.py @@ -72,6 +72,21 @@ def test_maximum_countdown_width_fits_the_live_screen_position(self): ) self.assertLessEqual(text_width, visible_width) + def test_maximum_g_readout_fits_round_screen_safe_area(self): + display_radius = 120 + y_position = 40 + text_height = pixel_height(2) + text_width = measure_text("MAX 99.99 g", 2, tabular_digits=True) + + for edge_y in (y_position, y_position + text_height - 1): + distance_from_center = edge_y - display_radius + visible_width = 2 * math.sqrt( + (display_radius ** 2) - (distance_from_center ** 2) + ) + self.assertLessEqual(text_width, visible_width) + + self.assertLess(y_position + text_height, 82) + def test_simulated_session_redraws_at_most_once_per_visible_second(self): clock = FakeClock() lcd = FakeLCD() @@ -147,6 +162,29 @@ def stop_check(): self.assertEqual("green", frames[0][3]) self.assertEqual("warning", frames[1][3]) + def test_sampling_runs_between_frames_without_forcing_redraws(self): + clock = FakeClock() + session = SessionTracker(duration_mins=1, clock=clock.time) + session.start_session() + samples = [] + + def stop_check(): + return len(samples) == 4 + + redraws = run_live_display( + session, + frame_builder=lambda now: ("same",), + draw_frame=lambda frame: None, + stop_check=stop_check, + sample_update=samples.append, + clock=clock, + ) + + self.assertEqual(1, redraws) + self.assertEqual(4, len(samples)) + for actual, expected in zip(samples, (0, 0.05, 0.1, 0.15)): + self.assertAlmostEqual(expected, actual) + def test_rgb_conversion_matches_native_primary_colour_constants(self): self.assertEqual(0x00F8, rgb_to_display565((255, 0, 0))) self.assertEqual(0xE007, rgb_to_display565((0, 255, 0))) @@ -228,11 +266,21 @@ def test_track_frames_follow_gradient_and_use_deep_purple_for_overrun(self): session = SessionTracker(duration_mins=10, clock=lambda: 100) session.start_session() - start = track_live_frame(session, 100, lcd) + start = track_live_frame( + session, + 100, + lcd, + maximum_g="MAX 0.00 g", + ) one_third = track_live_frame(session, 300, lcd) two_thirds = track_live_frame(session, 500, lcd) near_expiry = track_live_frame(session, 699, lcd) - overrun = track_live_frame(session, 700, lcd) + overrun = track_live_frame( + session, + 700, + lcd, + maximum_g="MAX 2.34 g", + ) self.assertTrue( all( @@ -248,22 +296,24 @@ def test_track_frames_follow_gradient_and_use_deep_purple_for_overrun(self): ) self.assertEqual( (rgb_to_display565(TRACK_GREEN_RGB), lcd.black), - start[3:], + start[3:5], ) self.assertEqual( (rgb_to_display565(TRACK_YELLOW_RGB), lcd.black), - one_third[3:], + one_third[3:5], ) self.assertEqual( (rgb_to_display565(TRACK_AMBER_RGB), lcd.black), - two_thirds[3:], + two_thirds[3:5], ) self.assertNotEqual(two_thirds[3], near_expiry[3]) self.assertEqual("00:00", overrun[0]) self.assertEqual( (rgb_to_display565(TRACK_OVERRUN_PURPLE_RGB), lcd.white), - overrun[3:], + overrun[3:5], ) + self.assertEqual("MAX 0.00 g", start[5]) + self.assertEqual("MAX 2.34 g", overrun[5]) def test_rest_frame_uses_larger_countdown_size(self): lcd = FakeLCD() @@ -273,6 +323,7 @@ def test_rest_frame_uses_larger_countdown_size(self): frame = rest_live_frame(session, 100, lcd) self.assertEqual(COUNTDOWN_TEXT_SIZE, frame[2]) + self.assertIsNone(frame[5]) def test_loop_delay_must_be_bounded(self): session = SessionTracker(duration_mins=1, live=True) diff --git a/tests/test_touch_mode.py b/tests/test_touch_mode.py index f94a5bc..043bc90 100644 --- a/tests/test_touch_mode.py +++ b/tests/test_touch_mode.py @@ -94,6 +94,49 @@ def test_clear_gesture_uses_viewer_relative_up(self): touch.Gestures = touch_drive.G_UP self.assertFalse(touch.ClearGesture(None)) + def test_live_screen_places_smaller_maximum_g_clear_of_countdown(self): + class FakeLCD: + green = 1 + white = 2 + + def __init__(self): + self.calls = [] + + def fill(self, colour): + self.calls.append(("fill", colour)) + + def write_time_centered(self, *args): + self.calls.append(("write_time_centered",) + args) + + def show(self): + self.calls.append(("show",)) + + touch = self.make_touch() + lcd = FakeLCD() + + touch.LiveScreen( + lcd, + textsize_rem=7, + backColour=lcd.green, + textColour=lcd.white, + elapsed="00:12", + remaining="19:48", + maximum_g="MAX 1.23 g", + ) + + text_calls = [ + call for call in lcd.calls if call[0] == "write_time_centered" + ] + self.assertEqual( + [ + ("write_time_centered", "MAX 1.23 g", 40, 2, lcd.white), + ("write_time_centered", "19:48", 82, 7, lcd.white), + ("write_time_centered", "00:12", 180, 3, lcd.white), + ], + text_calls, + ) + self.assertEqual(("show",), lcd.calls[-1]) + if __name__ == "__main__": unittest.main() diff --git a/timer_mode.py b/timer_mode.py index 777398c..4521210 100644 --- a/timer_mode.py +++ b/timer_mode.py @@ -4,6 +4,7 @@ from battery import BatteryMonitor from configuration import set_sensitivity, set_session +from g_force import SessionGPeak, calibrate_baseline from hardware import PeripheralError from hold_detector import HoldDetector from launch import accel_launch @@ -145,6 +146,37 @@ def run_timer_mode( time.sleep(0.05) + if qmi8658 is None: + qmi8658, imu_error = initialize_imu(1) + if auto_rotation is not None: + auto_rotation.set_sensor(qmi8658, imu_error) + + sensitivity = configured_sensitivity if qmi8658 is not None else 0 + session_baseline = None + session_g_peak = SessionGPeak() + if qmi8658 is not None: + touch.ControlScreen( + lcd, + text_array=[ + ["SESSION G", None, 52, 2, "white"], + ["Calibrating", None, 105, 2, "white"], + ["Keep device still", None, 150, 1, "white"], + ], + back_colour="black", + ) + try: + session_baseline = calibrate_baseline(qmi8658) + session_g_peak = SessionGPeak( + qmi8658, + baseline=session_baseline, + ) + except PeripheralError as error: + print("Session maximum G unavailable: {}".format(error)) + qmi8658 = None + sensitivity = 0 + if auto_rotation is not None: + auto_rotation.set_sensor(None, error) + if sensitivity > 0: touch.GoScreen( lcd, @@ -159,6 +191,7 @@ def run_timer_mode( qmi8658, sensitivity=sensitivity, cancel_check=lambda: touch.StopGesture(lcd), + baseline=session_baseline, ) except PeripheralError as error: qmi8658 = None @@ -171,12 +204,31 @@ def run_timer_mode( print("Launch mode cancelled or timed out.") continue + def sample_session_g(_now): + nonlocal qmi8658 + try: + session_g_peak.sample() + except PeripheralError as error: + print("Session maximum G paused: {}".format(error)) + session_g_peak.disable() + qmi8658 = None + if auto_rotation is not None: + auto_rotation.set_sensor(None, error) + track_session.start_session() run_live_display( track_session, - frame_builder=lambda now: track_live_frame(track_session, now, lcd), + frame_builder=lambda now: track_live_frame( + track_session, + now, + lcd, + maximum_g=session_g_peak.display_label( + now - track_session.start_time + ), + ), draw_frame=lambda frame: draw_live_frame(touch, lcd, frame), stop_check=lambda: touch.StopGesture(lcd), + sample_update=sample_session_g, ) touch.ControlScreen( diff --git a/touch_drive.py b/touch_drive.py index e52b08e..f6e0f54 100644 --- a/touch_drive.py +++ b/touch_drive.py @@ -13,6 +13,8 @@ G_RIGHT = 0x04 G_LONG_PRESS = 0x0C G_DOUBLE_CLIC = 0x0B +MAXIMUM_G_Y = 40 +MAXIMUM_G_TEXT_SIZE = 2 def _sleep_ms(clock, milliseconds): @@ -326,7 +328,16 @@ def GoScreen(self, LCD, text='..GO!', subtitle=None): self.Wait(LCD, 1) - def LiveScreen(self, LCD, textsize_rem=None, backColour=None, textColour=None, elapsed=None, remaining=None): + def LiveScreen( + self, + LCD, + textsize_rem=None, + backColour=None, + textColour=None, + elapsed=None, + remaining=None, + maximum_g=None, + ): self._update_auto_rotation(LCD, redraw=False) if remaining is None: remaining = "blank!" @@ -340,8 +351,15 @@ def LiveScreen(self, LCD, textsize_rem=None, backColour=None, textColour=None, e textsize_rem = 5 self.Set_Mode(0) LCD.fill(backColour) - LCD.write_time_centered(remaining,82,textsize_rem,textColour) - LCD.write_time_centered(elapsed,180,3,textColour) + if maximum_g is not None: + LCD.write_time_centered( + maximum_g, + MAXIMUM_G_Y, + MAXIMUM_G_TEXT_SIZE, + textColour, + ) + LCD.write_time_centered(remaining, 82, textsize_rem, textColour) + LCD.write_time_centered(elapsed, 180, 3, textColour) LCD.show()