From 4af905a4634d8cea5c18808ada4667ffd53474cf Mon Sep 17 00:00:00 2001 From: Shane Smiskol Date: Fri, 23 Jan 2026 00:30:46 -0800 Subject: [PATCH 01/15] some work --- selfdrive/selfdrived/selfdrived.py | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/selfdrive/selfdrived/selfdrived.py b/selfdrive/selfdrived/selfdrived.py index 997c7e37701153..291817539842d7 100755 --- a/selfdrive/selfdrived/selfdrived.py +++ b/selfdrive/selfdrived/selfdrived.py @@ -278,8 +278,9 @@ def update_events(self, CS): safety_mismatch = pandaState.safetyModel not in IGNORED_SAFETY_MODES # safety mismatch allows some time for pandad to set the safety mode and publish it back from panda - if (safety_mismatch and self.sm.frame*DT_CTRL > 10.) or pandaState.safetyRxChecksInvalid or self.mismatch_counter >= 200: - self.events.add(EventName.controlsMismatch) + # TODO: we can't actuate, not important, but why? + # if (safety_mismatch and self.sm.frame*DT_CTRL > 10.) or pandaState.safetyRxChecksInvalid or self.mismatch_counter >= 200: + # self.events.add(EventName.controlsMismatch) if log.PandaState.FaultType.relayMalfunction in pandaState.faults: self.events.add(EventName.relayMalfunction) @@ -351,12 +352,13 @@ def update_events(self, CS): if any((self.sm.frame - self.sm.recv_frame[s])*DT_CTRL > 10. for s in self.sensor_packets): self.events.add(EventName.sensorDataInvalid) - if not REPLAY: - # Check for mismatch between openpilot and car's PCM - cruise_mismatch = CS.cruiseState.enabled and (not self.enabled or not self.CP.pcmCruise) - self.cruise_mismatch_counter = self.cruise_mismatch_counter + 1 if cruise_mismatch else 0 - if self.cruise_mismatch_counter > int(6. / DT_CTRL): - self.events.add(EventName.cruiseMismatch) + # TODO: why failing? + # if not REPLAY: + # # Check for mismatch between openpilot and car's PCM + # cruise_mismatch = CS.cruiseState.enabled and (not self.enabled or not self.CP.pcmCruise) + # self.cruise_mismatch_counter = self.cruise_mismatch_counter + 1 if cruise_mismatch else 0 + # if self.cruise_mismatch_counter > int(6. / DT_CTRL): + # self.events.add(EventName.cruiseMismatch) # Send a "steering required alert" if saturation count has reached the limit if CS.steeringPressed: From 4711c8155d41acc8c30061686c59d30ee1322105 Mon Sep 17 00:00:00 2001 From: Shane Smiskol Date: Fri, 23 Jan 2026 00:37:47 -0800 Subject: [PATCH 02/15] bump opendbc --- opendbc_repo | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/opendbc_repo b/opendbc_repo index 796ece26acd8b9..9ee44cf28b9beb 160000 --- a/opendbc_repo +++ b/opendbc_repo @@ -1 +1 @@ -Subproject commit 796ece26acd8b9255810ca71941ed72626589ee7 +Subproject commit 9ee44cf28b9bebfec9e4ec30af4e0f232f329b6a From b6015edf5d38a8d92ed9096683f4924f5eaa2268 Mon Sep 17 00:00:00 2001 From: Shane Smiskol Date: Sat, 24 Jan 2026 21:35:13 -0800 Subject: [PATCH 03/15] epic manual stats --- common/params_keys.h | 3 + selfdrive/ui/mici/layouts/main.py | 35 ++ .../ui/mici/layouts/manual_drive_summary.py | 322 ++++++++++++++++++ .../ui/mici/layouts/settings/manual_stats.py | 266 +++++++++++++++ .../ui/mici/layouts/settings/settings.py | 8 +- .../ui/mici/onroad/augmented_road_view.py | 9 + .../ui/mici/onroad/manual_stats_widget.py | 119 +++++++ 7 files changed, 761 insertions(+), 1 deletion(-) create mode 100644 selfdrive/ui/mici/layouts/manual_drive_summary.py create mode 100644 selfdrive/ui/mici/layouts/settings/manual_stats.py create mode 100644 selfdrive/ui/mici/onroad/manual_stats_widget.py diff --git a/common/params_keys.h b/common/params_keys.h index d6104e749773dc..bb51fbb1a48386 100644 --- a/common/params_keys.h +++ b/common/params_keys.h @@ -84,6 +84,9 @@ inline static std::unordered_map keys = { {"LocationFilterInitialState", {PERSISTENT, BYTES}}, {"LongitudinalManeuverMode", {CLEAR_ON_MANAGER_START | CLEAR_ON_OFFROAD_TRANSITION, BOOL}}, {"LongitudinalPersonality", {PERSISTENT, INT, std::to_string(static_cast(cereal::LongitudinalPersonality::STANDARD))}}, + {"ManualDriveLiveStats", {CLEAR_ON_MANAGER_START, JSON}}, + {"ManualDriveLastSession", {PERSISTENT, JSON}}, + {"ManualDriveStats", {PERSISTENT, JSON}}, {"NetworkMetered", {PERSISTENT, BOOL}}, {"ObdMultiplexingChanged", {CLEAR_ON_MANAGER_START | CLEAR_ON_ONROAD_TRANSITION, BOOL}}, {"ObdMultiplexingEnabled", {CLEAR_ON_MANAGER_START | CLEAR_ON_ONROAD_TRANSITION, BOOL}}, diff --git a/selfdrive/ui/mici/layouts/main.py b/selfdrive/ui/mici/layouts/main.py index b52f9ed39a06f9..d0768fc76dff44 100644 --- a/selfdrive/ui/mici/layouts/main.py +++ b/selfdrive/ui/mici/layouts/main.py @@ -1,9 +1,12 @@ +import json import pyray as rl from enum import IntEnum import cereal.messaging as messaging +from openpilot.common.params import Params from openpilot.selfdrive.ui.mici.layouts.home import MiciHomeLayout from openpilot.selfdrive.ui.mici.layouts.settings.settings import SettingsLayout from openpilot.selfdrive.ui.mici.layouts.offroad_alerts import MiciOffroadAlerts +from openpilot.selfdrive.ui.mici.layouts.manual_drive_summary import ManualDriveSummaryDialog from openpilot.selfdrive.ui.mici.onroad.augmented_road_view import AugmentedRoadView from openpilot.selfdrive.ui.ui_state import device, ui_state from openpilot.selfdrive.ui.mici.layouts.onboarding import OnboardingWindow @@ -25,6 +28,7 @@ def __init__(self): super().__init__() self._pm = messaging.PubMaster(['bookmarkButton']) + self._params = Params() self._current_mode: MainState | None = None self._prev_onroad = False @@ -32,6 +36,9 @@ def __init__(self): self._onroad_time_delay: float | None = None self._setup = False + # Manual drive summary dialog + self._drive_summary_dialog: ManualDriveSummaryDialog | None = None + # Initialize widgets self._home_layout = MiciHomeLayout() self._alerts_layout = MiciOffroadAlerts() @@ -111,6 +118,8 @@ def _handle_transitions(self): if ui_state.started: self._onroad_time_delay = rl.get_time() else: + # Going offroad - show drive summary if manual car had data + self._show_drive_summary_if_available() self._set_mode_for_started(True) # delay so we show home for a bit after starting @@ -124,6 +133,32 @@ def _handle_transitions(self): self._scroll_to(self._onroad_layout) self._prev_standstill = CS.standstill + def _show_drive_summary_if_available(self): + """End manual stats session and show summary dialog if data exists""" + # Try to end the manual stats session + try: + from opendbc.car.subaru.manual_stats import get_tracker + tracker = get_tracker() + tracker.end_session() + except Exception: + pass + + # Show the summary dialog if there's session data + try: + data = self._params.get("ManualDriveLastSession") + if data: + session = json.loads(data) + # Only show if there's meaningful data (duration > 30s and some activity) + duration = session.get('duration', 0) + has_activity = (session.get('stall_count', 0) > 0 or + session.get('upshift_count', 0) > 0 or + session.get('launch_count', 0) > 0) + if duration > 30 and has_activity: + self._drive_summary_dialog = ManualDriveSummaryDialog() + gui_app.set_modal_overlay(self._drive_summary_dialog) + except Exception: + pass + def _set_mode_for_started(self, onroad_transition: bool = False): if ui_state.started: CS = ui_state.sm["carState"] diff --git a/selfdrive/ui/mici/layouts/manual_drive_summary.py b/selfdrive/ui/mici/layouts/manual_drive_summary.py new file mode 100644 index 00000000000000..ad655ccaf6a0da --- /dev/null +++ b/selfdrive/ui/mici/layouts/manual_drive_summary.py @@ -0,0 +1,322 @@ +""" +Manual Drive Summary Dialog + +Shows end-of-drive statistics for manual transmission driving with +encouraging or critical feedback based on performance. +""" + +import json +import time +import pyray as rl +from typing import Optional, Callable + +from openpilot.common.params import Params +from openpilot.system.ui.lib.application import gui_app, FontWeight, FONT_SCALE +from openpilot.system.ui.lib.wrap_text import wrap_text +from openpilot.system.ui.widgets import Widget + + +# Colors +GREEN = rl.Color(46, 204, 113, 255) +YELLOW = rl.Color(241, 196, 15, 255) +RED = rl.Color(231, 76, 60, 255) +GRAY = rl.Color(150, 150, 150, 255) +LIGHT_GRAY = rl.Color(200, 200, 200, 255) +BG_COLOR = rl.Color(30, 30, 30, 240) + + +class ManualDriveSummaryDialog(Widget): + """Modal dialog showing end-of-drive manual transmission stats""" + + def __init__(self, dismiss_callback: Optional[Callable] = None): + super().__init__() + self._params = Params() + self._dismiss_callback = dismiss_callback + self._session_data: Optional[dict] = None + self._overall_grade: str = "good" # good, ok, poor + self._card_rank: str = "10" # Poker card rank: 10, J, Q, K, A + self._show_time: float = 0.0 + self._auto_dismiss_after: float = 30.0 # Auto dismiss after 30 seconds + + def show_event(self): + super().show_event() + self._show_time = time.monotonic() + self._load_session() + + def _load_session(self): + """Load the last session data from Params""" + try: + data = self._params.get("ManualDriveLastSession") + if data: + self._session_data = json.loads(data) + self._calculate_grade() + except Exception: + self._session_data = None + + def _calculate_grade(self): + """Calculate overall grade based on session performance""" + if not self._session_data: + self._overall_grade = "ok" + self._card_rank = "10" + return + + # Calculate grade based on stalls, shifts, and launches + stalls = self._session_data.get('stall_count', 0) + lugs = self._session_data.get('lug_count', 0) + + # Shift quality + upshift_total = self._session_data.get('upshift_count', 0) + upshift_good = self._session_data.get('upshift_good', 0) + downshift_total = self._session_data.get('downshift_count', 0) + downshift_good = self._session_data.get('downshift_good', 0) + + # Launch quality + launch_total = self._session_data.get('launch_count', 0) + launch_good = self._session_data.get('launch_good', 0) + launch_stalled = self._session_data.get('launch_stalled', 0) + + # Calculate scores + total_shifts = upshift_total + downshift_total + shift_score = ((upshift_good + downshift_good) / total_shifts * 100) if total_shifts > 0 else 100 + launch_score = (launch_good / launch_total * 100) if launch_total > 0 else 100 + + # Penalties + stall_penalty = stalls * 20 + lug_penalty = lugs * 5 + launch_stall_penalty = launch_stalled * 15 + + overall_score = max(0, min(100, (shift_score + launch_score) / 2 - stall_penalty - lug_penalty - launch_stall_penalty)) + + # Poker card ranking: 10, J, Q, K, A + if overall_score >= 90 and stalls == 0: + self._card_rank = "A" + self._overall_grade = "good" + elif overall_score >= 75 and stalls == 0: + self._card_rank = "K" + self._overall_grade = "good" + elif overall_score >= 60 and stalls <= 1: + self._card_rank = "Q" + self._overall_grade = "ok" + elif overall_score >= 40: + self._card_rank = "J" + self._overall_grade = "ok" + else: + self._card_rank = "10" + self._overall_grade = "poor" + + def _get_header_text(self) -> tuple[str, rl.Color]: + """Get header text and color based on grade""" + if self._overall_grade == "good": + return "Waddle Driver!", GREEN + elif self._overall_grade == "ok": + return "Decent Drive", YELLOW + else: + return "Jackets...", RED + + def _get_encouragement_text(self) -> str: + """Get encouragement or criticism text based on performance""" + if not self._session_data: + return "No data available for this drive." + + stalls = self._session_data.get('stall_count', 0) + lugs = self._session_data.get('lug_count', 0) + launch_stalled = self._session_data.get('launch_stalled', 0) + + upshift_good = self._session_data.get('upshift_good', 0) + upshift_total = self._session_data.get('upshift_count', 0) + downshift_good = self._session_data.get('downshift_good', 0) + downshift_total = self._session_data.get('downshift_count', 0) + launch_good = self._session_data.get('launch_good', 0) + launch_total = self._session_data.get('launch_count', 0) + + messages = [] + + if self._overall_grade == "good": + if self._card_rank == "A": + messages.append("Ace drive! You're a true waddle master!") + elif self._card_rank == "K": + messages.append("King of the road! Waddling like a pro!") + if stalls == 0 and launch_stalled == 0: + messages.append("No stalls!") + if upshift_total > 0 and upshift_good == upshift_total: + messages.append("Perfect upshifts!") + if downshift_total > 0 and downshift_good >= downshift_total * 0.8: + messages.append("Great rev matching!") + if launch_total > 0 and launch_good >= launch_total * 0.8: + messages.append("Smooth launches!") + if not messages: + messages.append("Keep waddling!") + + elif self._overall_grade == "ok": + if self._card_rank == "Q": + messages.append("Queen-level driving - almost there!") + else: + messages.append("Jack of all gears - room to improve!") + if stalls > 0: + messages.append(f"Only {stalls} stall{'s' if stalls > 1 else ''} - improving!") + if lugs > 0: + messages.append(f"Watch RPMs - {lugs} lug{'s' if lugs > 1 else ''}.") + if upshift_total > 0 and upshift_good < upshift_total: + messages.append("Smoother upshifts needed.") + + else: # poor - jackets + messages.append("Time to hang up those jackets and try again!") + if stalls > 2: + messages.append(f"{stalls} stalls - more gas, slower clutch!") + if launch_stalled > 0: + messages.append(f"{launch_stalled} stalled launch{'es' if launch_stalled > 1 else ''} - find that bite point!") + if lugs > 3: + messages.append(f"Lugging {lugs} times - downshift sooner!") + if not messages[1:]: + messages.append("Every pro stalled at first. Keep at it!") + + return " ".join(messages) + + def _handle_mouse_release(self, _): + """Dismiss on tap""" + if self._dismiss_callback: + self._dismiss_callback() + gui_app.dismiss_modal() + + def _render(self, rect: rl.Rectangle): + if not self._session_data: + # Auto-dismiss if no data + if self._dismiss_callback: + self._dismiss_callback() + gui_app.dismiss_modal() + return + + # Auto-dismiss after timeout + if time.monotonic() - self._show_time > self._auto_dismiss_after: + if self._dismiss_callback: + self._dismiss_callback() + gui_app.dismiss_modal() + return + + # Draw semi-transparent background + rl.draw_rectangle(0, 0, gui_app.width, gui_app.height, rl.Color(0, 0, 0, 180)) + + # Dialog dimensions + dialog_w = min(500, gui_app.width - 40) + dialog_h = min(600, gui_app.height - 40) + dialog_x = (gui_app.width - dialog_w) // 2 + dialog_y = (gui_app.height - dialog_h) // 2 + + # Draw dialog background + rl.draw_rectangle_rounded( + rl.Rectangle(dialog_x, dialog_y, dialog_w, dialog_h), + 0.03, 10, BG_COLOR + ) + + # Content area + x = dialog_x + 30 + y = dialog_y + 25 + w = dialog_w - 60 + + # Header + header_text, header_color = self._get_header_text() + font = gui_app.font(FontWeight.BOLD) + rl.draw_text_ex(font, header_text, rl.Vector2(x, y), 48, 0, header_color) + y += 55 + + # Card rank display - poker hand style + card_names = {"A": "Aces", "K": "Kings", "Q": "Queens", "J": "Jacks", "10": "10s"} + card_color = GREEN if self._card_rank in ("A", "K") else (YELLOW if self._card_rank in ("Q", "J") else RED) + card_text = f"Your hand: {card_names[self._card_rank]}" + rl.draw_text_ex(gui_app.font(FontWeight.MEDIUM), card_text, rl.Vector2(x, y), 32, 0, card_color) + y += 45 + + # Duration + duration = self._session_data.get('duration', 0) + duration_min = int(duration // 60) + duration_sec = int(duration % 60) + rl.draw_text_ex(gui_app.font(FontWeight.ROMAN), f"Drive Duration: {duration_min}:{duration_sec:02d}", + rl.Vector2(x, y), 28, 0, GRAY) + y += 45 + + # Separator + rl.draw_rectangle(x, y, w, 2, rl.Color(60, 60, 60, 255)) + y += 15 + + # Stats sections + y = self._draw_stat_section(x, y, w, "Stalls", self._session_data.get('stall_count', 0), target=0, lower_better=True) + y = self._draw_stat_section(x, y, w, "Engine Lugs", self._session_data.get('lug_count', 0), target=0, lower_better=True) + + # Launches + launch_total = self._session_data.get('launch_count', 0) + launch_good = self._session_data.get('launch_good', 0) + launch_stalled = self._session_data.get('launch_stalled', 0) + if launch_total > 0: + y = self._draw_stat_section(x, y, w, "Good Launches", f"{launch_good}/{launch_total}", + target=launch_total, current=launch_good) + if launch_stalled > 0: + y = self._draw_stat_section(x, y, w, "Stalled Launches", launch_stalled, target=0, lower_better=True) + + # Upshifts + upshift_total = self._session_data.get('upshift_count', 0) + upshift_good = self._session_data.get('upshift_good', 0) + if upshift_total > 0: + y = self._draw_stat_section(x, y, w, "Good Upshifts", f"{upshift_good}/{upshift_total}", + target=upshift_total, current=upshift_good) + + # Downshifts + downshift_total = self._session_data.get('downshift_count', 0) + downshift_good = self._session_data.get('downshift_good', 0) + if downshift_total > 0: + y = self._draw_stat_section(x, y, w, "Good Downshifts", f"{downshift_good}/{downshift_total}", + target=downshift_total, current=downshift_good) + + y += 10 + + # Encouragement/criticism text + encouragement = self._get_encouragement_text() + wrapped = wrap_text(gui_app.font(FontWeight.ROMAN), encouragement, 24, w) + for line in wrapped: + rl.draw_text_ex(gui_app.font(FontWeight.ROMAN), line, rl.Vector2(x, y), 24, 0, LIGHT_GRAY) + y += int(24 * FONT_SCALE) + + # Tap to dismiss hint + hint_text = "Tap to dismiss" + hint_font = gui_app.font(FontWeight.ROMAN) + hint_size = 20 + rl.draw_text_ex(hint_font, hint_text, rl.Vector2(dialog_x + dialog_w // 2 - 50, dialog_y + dialog_h - 35), + hint_size, 0, GRAY) + + def _draw_stat_section(self, x: int, y: int, w: int, label: str, value, target=None, + current=None, lower_better=False) -> int: + """Draw a stat row with label and value, colored based on performance""" + font = gui_app.font(FontWeight.MEDIUM) + font_size = 28 + + # Determine color based on target + if target is not None: + if lower_better: + if value == 0: + color = GREEN + elif value <= 2: + color = YELLOW + else: + color = RED + else: + if current is not None: + ratio = current / target if target > 0 else 1 + if ratio >= 0.8: + color = GREEN + elif ratio >= 0.5: + color = YELLOW + else: + color = RED + else: + color = LIGHT_GRAY + else: + color = LIGHT_GRAY + + # Draw label + rl.draw_text_ex(font, label, rl.Vector2(x, y), font_size, 0, LIGHT_GRAY) + + # Draw value (right-aligned) + value_str = str(value) + value_width = rl.measure_text_ex(font, value_str, font_size, 0).x + rl.draw_text_ex(font, value_str, rl.Vector2(x + w - value_width, y), font_size, 0, color) + + return y + 38 diff --git a/selfdrive/ui/mici/layouts/settings/manual_stats.py b/selfdrive/ui/mici/layouts/settings/manual_stats.py new file mode 100644 index 00000000000000..ca198761768c69 --- /dev/null +++ b/selfdrive/ui/mici/layouts/settings/manual_stats.py @@ -0,0 +1,266 @@ +""" +Manual Driving Stats Settings Page + +Shows historical stats and trends for manual transmission driving. +""" + +import json +import pyray as rl + +from openpilot.common.params import Params +from openpilot.system.ui.lib.application import gui_app, FontWeight, FONT_SCALE +from openpilot.system.ui.lib.scroll_panel2 import GuiScrollPanel2 +from openpilot.system.ui.lib.wrap_text import wrap_text +from openpilot.system.ui.widgets import Widget, NavWidget + + +# Colors +GREEN = rl.Color(46, 204, 113, 255) +YELLOW = rl.Color(241, 196, 15, 255) +RED = rl.Color(231, 76, 60, 255) +GRAY = rl.Color(100, 100, 100, 255) +LIGHT_GRAY = rl.Color(180, 180, 180, 255) +WHITE = rl.Color(255, 255, 255, 255) +BG_CARD = rl.Color(45, 45, 45, 255) + + +class ManualStatsLayout(NavWidget): + """Settings page showing historical manual driving stats""" + + def __init__(self, back_callback): + super().__init__() + self._params = Params() + self._scroll_panel = GuiScrollPanel2(horizontal=False) + self._stats: dict = {} + self.set_back_callback(back_callback) + + def show_event(self): + super().show_event() + self._scroll_panel.set_offset(0) + self._load_stats() + + def _load_stats(self): + """Load historical stats from Params""" + try: + data = self._params.get("ManualDriveStats") + if data: + # Params returns dict directly for JSON type + self._stats = data if isinstance(data, dict) else json.loads(data) + else: + self._stats = {} + except Exception: + self._stats = {} + + def _render(self, rect: rl.Rectangle): + content_height = self._measure_content_height(rect) + scroll_offset = round(self._scroll_panel.update(rect, content_height)) + + x = int(rect.x + 20) + y = int(rect.y + 20 + scroll_offset) + w = int(rect.width - 40) + + # Title + font_bold = gui_app.font(FontWeight.BOLD) + font_medium = gui_app.font(FontWeight.MEDIUM) + font_roman = gui_app.font(FontWeight.ROMAN) + + rl.draw_text_ex(font_bold, "Manual Driving Stats", rl.Vector2(x, y), 48, 0, WHITE) + y += 60 + + if not self._stats or self._stats.get('total_drives', 0) == 0: + rl.draw_text_ex(font_roman, "No driving data yet. Get out there and practice!", + rl.Vector2(x, y), 28, 0, GRAY) + return + + # Overview card + y = self._draw_card(x, y, w, "Overview", [ + ("Total Drives", str(self._stats.get('total_drives', 0)), WHITE), + ("Total Drive Time", self._format_time(self._stats.get('total_drive_time', 0)), WHITE), + ("Total Stalls", str(self._stats.get('total_stalls', 0)), self._stall_color(self._stats.get('total_stalls', 0))), + ("Total Lugs", str(self._stats.get('total_lugs', 0)), LIGHT_GRAY), + ]) + y += 15 + + # Shift quality card + total_up = self._stats.get('total_upshifts', 0) + total_down = self._stats.get('total_downshifts', 0) + up_good = self._stats.get('total_upshifts_good', 0) + down_good = self._stats.get('total_downshifts_good', 0) + + up_pct = f"{int(up_good / total_up * 100)}%" if total_up > 0 else "N/A" + down_pct = f"{int(down_good / total_down * 100)}%" if total_down > 0 else "N/A" + + y = self._draw_card(x, y, w, "Shift Quality", [ + ("Total Upshifts", str(total_up), WHITE), + ("Good Upshifts", f"{up_good} ({up_pct})", self._pct_color(up_good, total_up)), + ("Total Downshifts", str(total_down), WHITE), + ("Good Downshifts", f"{down_good} ({down_pct})", self._pct_color(down_good, total_down)), + ]) + y += 15 + + # Launch quality card + total_launches = self._stats.get('total_launches', 0) + good_launches = self._stats.get('total_launches_good', 0) + stalled_launches = self._stats.get('total_launches_stalled', 0) + + launch_pct = f"{int(good_launches / total_launches * 100)}%" if total_launches > 0 else "N/A" + + y = self._draw_card(x, y, w, "Launch Quality", [ + ("Total Launches", str(total_launches), WHITE), + ("Good Launches", f"{good_launches} ({launch_pct})", self._pct_color(good_launches, total_launches)), + ("Stalled Launches", str(stalled_launches), RED if stalled_launches > 0 else GREEN), + ]) + y += 15 + + # Trend card + recent_stalls = self._stats.get('recent_stall_rates', []) + recent_shifts = self._stats.get('recent_shift_scores', []) + + trend_items = [] + if len(recent_stalls) >= 2: + trend = self._calculate_trend(recent_stalls) + trend_text, trend_color = self._trend_text(trend, lower_better=True) + trend_items.append(("Stall Trend", trend_text, trend_color)) + + if len(recent_shifts) >= 2: + trend = self._calculate_trend(recent_shifts) + trend_text, trend_color = self._trend_text(trend, lower_better=False) + trend_items.append(("Shift Score Trend", trend_text, trend_color)) + + if recent_shifts: + avg_score = sum(recent_shifts) / len(recent_shifts) + trend_items.append(("Avg Shift Score (last 10)", f"{int(avg_score)}/100", self._score_color(avg_score))) + + if trend_items: + y = self._draw_card(x, y, w, "Recent Trends", trend_items) + y += 15 + + # Encouragement based on progress (with text wrapping) + y += 10 + encouragement = self._get_encouragement() + wrapped_lines = wrap_text(font_roman, encouragement, 24, w - 10) + for line in wrapped_lines: + rl.draw_text_ex(font_roman, line, rl.Vector2(x, y), 24, 0, LIGHT_GRAY) + y += 30 + + def _draw_card(self, x: int, y: int, w: int, title: str, items: list) -> int: + """Draw a card with title and stat items""" + font_bold = gui_app.font(FontWeight.BOLD) + font_medium = gui_app.font(FontWeight.MEDIUM) + + card_h = 50 + len(items) * 38 + rl.draw_rectangle_rounded(rl.Rectangle(x, y, w, card_h), 0.02, 10, BG_CARD) + + # Title + rl.draw_text_ex(font_bold, title, rl.Vector2(x + 15, y + 12), 32, 0, WHITE) + y += 50 + + # Items + for label, value, color in items: + rl.draw_text_ex(font_medium, label, rl.Vector2(x + 15, y), 26, 0, LIGHT_GRAY) + value_width = rl.measure_text_ex(font_medium, value, 26, 0).x + rl.draw_text_ex(font_medium, value, rl.Vector2(x + w - 15 - value_width, y), 26, 0, color) + y += 38 + + return y + + def _measure_content_height(self, rect: rl.Rectangle) -> int: + """Measure total content height for scrolling""" + y = 20 + 60 # Title + + if not self._stats or self._stats.get('total_drives', 0) == 0: + return y + 40 + + # Overview card + y += 50 + 4 * 38 + 15 + # Shift card + y += 50 + 4 * 38 + 15 + # Launch card + y += 50 + 3 * 38 + 15 + # Trend card (estimate) + y += 50 + 3 * 38 + 15 + # Encouragement (estimate 2-3 lines wrapped) + y += 100 + + return y + 40 # padding + + def _format_time(self, seconds: float) -> str: + """Format seconds as hours:minutes""" + hours = int(seconds // 3600) + minutes = int((seconds % 3600) // 60) + if hours > 0: + return f"{hours}h {minutes}m" + return f"{minutes}m" + + def _stall_color(self, stalls: int) -> rl.Color: + if stalls == 0: + return GREEN + elif stalls < 5: + return YELLOW + return RED + + def _pct_color(self, good: int, total: int) -> rl.Color: + if total == 0: + return GRAY + pct = good / total + if pct >= 0.8: + return GREEN + elif pct >= 0.5: + return YELLOW + return RED + + def _score_color(self, score: float) -> rl.Color: + if score >= 80: + return GREEN + elif score >= 50: + return YELLOW + return RED + + def _calculate_trend(self, values: list) -> float: + """Calculate trend as average change over recent values""" + if len(values) < 2: + return 0.0 + # Compare first half avg to second half avg + mid = len(values) // 2 + first_half = sum(values[:mid]) / mid if mid > 0 else 0 + second_half = sum(values[mid:]) / (len(values) - mid) if len(values) - mid > 0 else 0 + return second_half - first_half + + def _trend_text(self, trend: float, lower_better: bool) -> tuple[str, rl.Color]: + """Get trend text and color""" + if abs(trend) < 0.5: + return "Stable", LIGHT_GRAY + + if lower_better: + if trend < 0: + return "Improving!", GREEN + return "Getting worse", RED + else: + if trend > 0: + return "Improving!", GREEN + return "Getting worse", RED + + def _get_encouragement(self) -> str: + """Get encouragement based on overall progress""" + total_drives = self._stats.get('total_drives', 0) + total_stalls = self._stats.get('total_stalls', 0) + recent_stalls = self._stats.get('recent_stall_rates', []) + + if total_drives == 0: + return "Start driving to see your stats!" + + stall_rate = total_stalls / total_drives if total_drives > 0 else 0 + + if len(recent_stalls) >= 3: + recent_avg = sum(recent_stalls[-3:]) / 3 + if recent_avg == 0: + return "No stalls in recent drives - you're getting the hang of it!" + elif recent_avg < stall_rate: + return "Your recent drives are better than average - keep it up!" + + if stall_rate < 0.5: + return "Less than 1 stall per 2 drives on average - nice work!" + elif stall_rate < 1: + return "About 1 stall per drive - you're learning fast!" + else: + return "Keep practicing! Everyone stalls when learning manual." diff --git a/selfdrive/ui/mici/layouts/settings/settings.py b/selfdrive/ui/mici/layouts/settings/settings.py index a452777748e295..fc4ca77874537b 100644 --- a/selfdrive/ui/mici/layouts/settings/settings.py +++ b/selfdrive/ui/mici/layouts/settings/settings.py @@ -11,6 +11,7 @@ from openpilot.selfdrive.ui.mici.layouts.settings.device import DeviceLayoutMici, PairBigButton from openpilot.selfdrive.ui.mici.layouts.settings.developer import DeveloperLayoutMici from openpilot.selfdrive.ui.mici.layouts.settings.firehose import FirehoseLayout +from openpilot.selfdrive.ui.mici.layouts.settings.manual_stats import ManualStatsLayout from openpilot.system.ui.lib.application import gui_app, FontWeight from openpilot.system.ui.widgets import Widget, NavWidget @@ -22,6 +23,7 @@ class PanelType(IntEnum): DEVELOPER = 3 USER_MANUAL = 4 FIREHOSE = 5 + MANUAL_STATS = 6 @dataclass @@ -48,12 +50,15 @@ def __init__(self): firehose_btn = BigButton("firehose", "", "icons_mici/settings/comma_icon.png") firehose_btn.set_click_callback(lambda: self._set_current_panel(PanelType.FIREHOSE)) + manual_stats_btn = BigButton("MT stats", "", "icons_mici/settings/toggles_icon.png") + manual_stats_btn.set_click_callback(lambda: self._set_current_panel(PanelType.MANUAL_STATS)) + self._scroller = Scroller([ toggles_btn, + manual_stats_btn, # MT Stats right after Toggles network_btn, device_btn, PairBigButton(), - #BigDialogButton("manual", "", "icons_mici/settings/manual_icon.png", "Check out the mici user\nmanual at comma.ai/setup"), firehose_btn, developer_btn, ], snap_items=False) @@ -68,6 +73,7 @@ def __init__(self): PanelType.DEVICE: PanelInfo("Device", DeviceLayoutMici(back_callback=lambda: self._set_current_panel(None))), PanelType.DEVELOPER: PanelInfo("Developer", DeveloperLayoutMici(back_callback=lambda: self._set_current_panel(None))), PanelType.FIREHOSE: PanelInfo("Firehose", FirehoseLayout(back_callback=lambda: self._set_current_panel(None))), + PanelType.MANUAL_STATS: PanelInfo("MT Stats", ManualStatsLayout(back_callback=lambda: self._set_current_panel(None))), } self._font_medium = gui_app.font(FontWeight.MEDIUM) diff --git a/selfdrive/ui/mici/onroad/augmented_road_view.py b/selfdrive/ui/mici/onroad/augmented_road_view.py index 71ca03cccfac94..c8737341a1ee9f 100644 --- a/selfdrive/ui/mici/onroad/augmented_road_view.py +++ b/selfdrive/ui/mici/onroad/augmented_road_view.py @@ -11,6 +11,7 @@ from openpilot.selfdrive.ui.mici.onroad.model_renderer import ModelRenderer from openpilot.selfdrive.ui.mici.onroad.confidence_ball import ConfidenceBall from openpilot.selfdrive.ui.mici.onroad.cameraview import CameraView +from openpilot.selfdrive.ui.mici.onroad.manual_stats_widget import ManualStatsWidget from openpilot.system.ui.lib.application import FontWeight, gui_app, MousePos, MouseEvent from openpilot.system.ui.widgets.label import UnifiedLabel from openpilot.system.ui.widgets import Widget @@ -161,6 +162,9 @@ def __init__(self, bookmark_callback=None, stream_type: VisionStreamType = Visio self._fade_texture = gui_app.texture("icons_mici/onroad/onroad_fade.png") + # Manual stats widget for MT cars + self._manual_stats_widget = ManualStatsWidget() + # debug self._pm = messaging.PubMaster(['uiDebug']) @@ -242,6 +246,11 @@ def _render(self, _): # Use self._content_rect for positioning within camera bounds self._confidence_ball.render(self.rect) + # Manual stats widget for MT cars - check if manual transmission (flag 128) + is_manual = ui_state.CP is not None and bool(ui_state.CP.flags & 128) + self._manual_stats_widget.set_visible(is_manual and ui_state.started) + self._manual_stats_widget.render(self._content_rect) + self._bookmark_icon.render(self.rect) # Draw darkened background and text if not onroad diff --git a/selfdrive/ui/mici/onroad/manual_stats_widget.py b/selfdrive/ui/mici/onroad/manual_stats_widget.py new file mode 100644 index 00000000000000..93ad4d6e397f9b --- /dev/null +++ b/selfdrive/ui/mici/onroad/manual_stats_widget.py @@ -0,0 +1,119 @@ +""" +Live Manual Stats Widget + +Small onroad overlay showing current drive statistics and shift suggestions. +""" + +import json +import pyray as rl + +from openpilot.common.params import Params +from openpilot.system.ui.lib.application import gui_app, FontWeight +from openpilot.system.ui.widgets import Widget + + +# Colors +GREEN = rl.Color(46, 204, 113, 220) +YELLOW = rl.Color(241, 196, 15, 220) +RED = rl.Color(231, 76, 60, 220) +CYAN = rl.Color(52, 152, 219, 220) +WHITE = rl.Color(255, 255, 255, 220) +GRAY = rl.Color(150, 150, 150, 200) +BG_COLOR = rl.Color(0, 0, 0, 160) + + +class ManualStatsWidget(Widget): + """Small widget showing live manual driving stats and shift suggestions""" + + def __init__(self): + super().__init__() + self._params = Params() + self._visible = False + self._stats: dict = {} + self._update_counter = 0 + + def set_visible(self, visible: bool): + self._visible = visible + + def _render(self, rect: rl.Rectangle): + if not self._visible: + return + + # Update stats every ~15 frames (0.25s at 60fps) + self._update_counter += 1 + if self._update_counter >= 15: + self._update_counter = 0 + self._load_stats() + + if not self._stats: + return + + # Widget dimensions + w = 140 + h = 130 + x = int(rect.x + rect.width - w - 10) + y = int(rect.y + 10) + + # Background + rl.draw_rectangle_rounded(rl.Rectangle(x, y, w, h), 0.1, 10, BG_COLOR) + + font = gui_app.font(FontWeight.MEDIUM) + font_bold = gui_app.font(FontWeight.BOLD) + px = x + 10 + py = y + 8 + + # Current gear (big) + gear = self._stats.get('gear', 0) + gear_text = str(gear) if gear > 0 else "N" + rl.draw_text_ex(font_bold, gear_text, rl.Vector2(px, py), 42, 0, WHITE) + + # Shift suggestion next to gear + suggestion = self._stats.get('shift_suggestion', 'ok') + reason = self._stats.get('shift_reason', '') + if suggestion == 'upshift': + rl.draw_text_ex(font_bold, "↑", rl.Vector2(px + 35, py + 5), 36, 0, GREEN) + elif suggestion == 'downshift': + rl.draw_text_ex(font_bold, "↓", rl.Vector2(px + 35, py + 5), 36, 0, YELLOW) + + py += 48 + + # Stats in smaller text + font_size = 20 + line_h = 24 + + # Stalls + stalls = self._stats.get('stalls', 0) + color = GREEN if stalls == 0 else (YELLOW if stalls <= 2 else RED) + rl.draw_text_ex(font, f"Stalls: {stalls}", rl.Vector2(px, py), font_size, 0, color) + py += line_h + + # Lugging indicator + is_lugging = self._stats.get('is_lugging', False) + lugs = self._stats.get('lugs', 0) + if is_lugging: + rl.draw_text_ex(font, "LUGGING!", rl.Vector2(px, py), font_size, 0, RED) + else: + color = GREEN if lugs == 0 else GRAY + rl.draw_text_ex(font, f"Lugs: {lugs}", rl.Vector2(px, py), font_size, 0, color) + py += line_h + + # Shift quality + shifts = self._stats.get('shifts', 0) + good_shifts = self._stats.get('good_shifts', 0) + if shifts > 0: + pct = int(good_shifts / shifts * 100) + color = GREEN if pct >= 80 else (YELLOW if pct >= 50 else RED) + rl.draw_text_ex(font, f"Shifts: {pct}%", rl.Vector2(px, py), font_size, 0, color) + else: + rl.draw_text_ex(font, "Shifts: -", rl.Vector2(px, py), font_size, 0, GRAY) + + def _load_stats(self): + """Load current session stats""" + try: + data = self._params.get("ManualDriveLiveStats") + if data: + self._stats = json.loads(data) + else: + self._stats = {} + except Exception: + self._stats = {} From ca4c42dd513efa9793dd5887f979b51a23fe90c8 Mon Sep 17 00:00:00 2001 From: Shane Smiskol Date: Sat, 24 Jan 2026 21:55:46 -0800 Subject: [PATCH 04/15] "improvements" --- .../ui/mici/layouts/manual_drive_summary.py | 273 +++++++++--- .../ui/mici/layouts/settings/manual_stats.py | 395 +++++++++++++++++- .../ui/mici/layouts/settings/settings.py | 2 +- 3 files changed, 591 insertions(+), 79 deletions(-) diff --git a/selfdrive/ui/mici/layouts/manual_drive_summary.py b/selfdrive/ui/mici/layouts/manual_drive_summary.py index ad655ccaf6a0da..62c5a82c0b8edf 100644 --- a/selfdrive/ui/mici/layouts/manual_drive_summary.py +++ b/selfdrive/ui/mici/layouts/manual_drive_summary.py @@ -3,6 +3,7 @@ Shows end-of-drive statistics for manual transmission driving with encouraging or critical feedback based on performance. +Poker hand themed with waddle/jacket references. """ import json @@ -20,9 +21,29 @@ GREEN = rl.Color(46, 204, 113, 255) YELLOW = rl.Color(241, 196, 15, 255) RED = rl.Color(231, 76, 60, 255) +ORANGE = rl.Color(230, 126, 34, 255) GRAY = rl.Color(150, 150, 150, 255) LIGHT_GRAY = rl.Color(200, 200, 200, 255) -BG_COLOR = rl.Color(30, 30, 30, 240) +WHITE = rl.Color(255, 255, 255, 255) +BG_COLOR = rl.Color(30, 30, 30, 245) +BG_CARD = rl.Color(45, 45, 45, 255) + +# Poker hand names +HAND_NAMES = { + "A": "Aces", + "K": "Kings", + "Q": "Queens", + "J": "Jacks", + "10": "10s" +} + +HAND_SUBTITLES = { + "A": "Porch-worthy! KP!", + "K": "CCM vibes! QG!", + "Q": "Priest-approved", + "J": "Not SS... yet", + "10": "Jacketed! Huge oof" +} class ManualDriveSummaryDialog(Widget): @@ -33,8 +54,11 @@ def __init__(self, dismiss_callback: Optional[Callable] = None): self._params = Params() self._dismiss_callback = dismiss_callback self._session_data: Optional[dict] = None + self._historical_data: Optional[dict] = None self._overall_grade: str = "good" # good, ok, poor self._card_rank: str = "10" # Poker card rank: 10, J, Q, K, A + self._shift_score: float = 0.0 + self._avg_shift_score: float = 0.0 self._show_time: float = 0.0 self._auto_dismiss_after: float = 30.0 # Auto dismiss after 30 seconds @@ -42,22 +66,47 @@ def show_event(self): super().show_event() self._show_time = time.monotonic() self._load_session() + self._load_historical() def _load_session(self): """Load the last session data from Params""" try: data = self._params.get("ManualDriveLastSession") if data: - self._session_data = json.loads(data) + self._session_data = data if isinstance(data, dict) else json.loads(data) self._calculate_grade() except Exception: self._session_data = None + def _load_historical(self): + """Load historical stats for comparison""" + try: + data = self._params.get("ManualDriveStats") + if data: + self._historical_data = data if isinstance(data, dict) else json.loads(data) + # Calculate average shift score from history + history = self._historical_data.get('session_history', []) + if history: + scores = [] + for s in history[-10:]: # Last 10 sessions + ups = s.get('upshifts', 0) + ups_good = s.get('upshifts_good', 0) + downs = s.get('downshifts', 0) + downs_good = s.get('downshifts_good', 0) + total = ups + downs + if total > 0: + scores.append((ups_good + downs_good) / total * 100) + if scores: + self._avg_shift_score = sum(scores) / len(scores) + except Exception: + self._historical_data = None + def _calculate_grade(self): """Calculate overall grade based on session performance""" if not self._session_data: self._overall_grade = "ok" self._card_rank = "10" + self._shift_score = 0 return # Calculate grade based on stalls, shifts, and launches @@ -77,7 +126,7 @@ def _calculate_grade(self): # Calculate scores total_shifts = upshift_total + downshift_total - shift_score = ((upshift_good + downshift_good) / total_shifts * 100) if total_shifts > 0 else 100 + self._shift_score = ((upshift_good + downshift_good) / total_shifts * 100) if total_shifts > 0 else 100 launch_score = (launch_good / launch_total * 100) if launch_total > 0 else 100 # Penalties @@ -85,7 +134,7 @@ def _calculate_grade(self): lug_penalty = lugs * 5 launch_stall_penalty = launch_stalled * 15 - overall_score = max(0, min(100, (shift_score + launch_score) / 2 - stall_penalty - lug_penalty - launch_stall_penalty)) + overall_score = max(0, min(100, (self._shift_score + launch_score) / 2 - stall_penalty - lug_penalty - launch_stall_penalty)) # Poker card ranking: 10, J, Q, K, A if overall_score >= 90 and stalls == 0: @@ -132,43 +181,55 @@ def _get_encouragement_text(self) -> str: messages = [] if self._overall_grade == "good": - if self._card_rank == "A": - messages.append("Ace drive! You're a true waddle master!") + # Check for perfect drive - Kacper glasses moment + total_shifts = upshift_total + downshift_total + total_good = upshift_good + downshift_good + perfect_shifts = total_shifts > 0 and total_good == total_shifts + perfect_launches = launch_total > 0 and launch_good == launch_total + + if self._card_rank == "A" and stalls == 0 and lugs == 0 and perfect_shifts and perfect_launches: + messages.append("PERFECT! Waddle is driving! Kacper threw his glasses!") + elif self._card_rank == "A": + messages.append("Aces! Porch-worthy waddle, KP earned!") elif self._card_rank == "K": - messages.append("King of the road! Waddling like a pro!") + messages.append("Kings! Waddle energy, CCM vibes!") if stalls == 0 and launch_stalled == 0: messages.append("No stalls!") - if upshift_total > 0 and upshift_good == upshift_total: + if perfect_shifts: + messages.append("Perfect shifts - priest-approved!") + elif upshift_total > 0 and upshift_good == upshift_total: messages.append("Perfect upshifts!") if downshift_total > 0 and downshift_good >= downshift_total * 0.8: messages.append("Great rev matching!") - if launch_total > 0 and launch_good >= launch_total * 0.8: + if perfect_launches: + messages.append("Flawless launches!") + elif launch_total > 0 and launch_good >= launch_total * 0.8: messages.append("Smooth launches!") if not messages: - messages.append("Keep waddling!") + messages.append("Keep channeling waddle!") elif self._overall_grade == "ok": if self._card_rank == "Q": - messages.append("Queen-level driving - almost there!") + messages.append("Queens - almost there!") else: - messages.append("Jack of all gears - room to improve!") + messages.append("Jacks - improving, not SS!") if stalls > 0: - messages.append(f"Only {stalls} stall{'s' if stalls > 1 else ''} - improving!") + messages.append(f"Only {stalls} stall{'s' if stalls > 1 else ''} - shedding jackets!") if lugs > 0: messages.append(f"Watch RPMs - {lugs} lug{'s' if lugs > 1 else ''}.") if upshift_total > 0 and upshift_good < upshift_total: messages.append("Smoother upshifts needed.") else: # poor - jackets - messages.append("Time to hang up those jackets and try again!") + messages.append("Jacketed! Huge oof. SS vibes!") if stalls > 2: messages.append(f"{stalls} stalls - more gas, slower clutch!") if launch_stalled > 0: - messages.append(f"{launch_stalled} stalled launch{'es' if launch_stalled > 1 else ''} - find that bite point!") + messages.append(f"{launch_stalled} stalled launch{'es' if launch_stalled > 1 else ''} - find bite point!") if lugs > 3: - messages.append(f"Lugging {lugs} times - downshift sooner!") + messages.append(f"Lugging {lugs}x - downshift sooner!") if not messages[1:]: - messages.append("Every pro stalled at first. Keep at it!") + messages.append("Even the best got jacketed at first. QG!") return " ".join(messages) @@ -197,8 +258,8 @@ def _render(self, rect: rl.Rectangle): rl.draw_rectangle(0, 0, gui_app.width, gui_app.height, rl.Color(0, 0, 0, 180)) # Dialog dimensions - dialog_w = min(500, gui_app.width - 40) - dialog_h = min(600, gui_app.height - 40) + dialog_w = min(520, gui_app.width - 40) + dialog_h = min(680, gui_app.height - 40) dialog_x = (gui_app.width - dialog_w) // 2 dialog_y = (gui_app.height - dialog_h) // 2 @@ -209,78 +270,162 @@ def _render(self, rect: rl.Rectangle): ) # Content area - x = dialog_x + 30 - y = dialog_y + 25 - w = dialog_w - 60 + x = dialog_x + 25 + y = dialog_y + 20 + w = dialog_w - 50 + + font_bold = gui_app.font(FontWeight.BOLD) + font_medium = gui_app.font(FontWeight.MEDIUM) + font_roman = gui_app.font(FontWeight.ROMAN) # Header header_text, header_color = self._get_header_text() - font = gui_app.font(FontWeight.BOLD) - rl.draw_text_ex(font, header_text, rl.Vector2(x, y), 48, 0, header_color) - y += 55 + rl.draw_text_ex(font_bold, header_text, rl.Vector2(x, y), 44, 0, header_color) + y += 50 - # Card rank display - poker hand style - card_names = {"A": "Aces", "K": "Kings", "Q": "Queens", "J": "Jacks", "10": "10s"} + # Card rank display - poker hand style with subtitle card_color = GREEN if self._card_rank in ("A", "K") else (YELLOW if self._card_rank in ("Q", "J") else RED) - card_text = f"Your hand: {card_names[self._card_rank]}" - rl.draw_text_ex(gui_app.font(FontWeight.MEDIUM), card_text, rl.Vector2(x, y), 32, 0, card_color) - y += 45 + card_text = f"Your hand: {HAND_NAMES[self._card_rank]}" + rl.draw_text_ex(font_medium, card_text, rl.Vector2(x, y), 28, 0, card_color) + # Subtitle + subtitle = HAND_SUBTITLES[self._card_rank] + subtitle_width = rl.measure_text_ex(font_roman, subtitle, 20, 0).x + rl.draw_text_ex(font_roman, subtitle, rl.Vector2(x + w - subtitle_width, y + 4), 20, 0, card_color) + y += 38 # Duration duration = self._session_data.get('duration', 0) duration_min = int(duration // 60) duration_sec = int(duration % 60) - rl.draw_text_ex(gui_app.font(FontWeight.ROMAN), f"Drive Duration: {duration_min}:{duration_sec:02d}", - rl.Vector2(x, y), 28, 0, GRAY) - y += 45 + rl.draw_text_ex(font_roman, f"Drive: {duration_min}:{duration_sec:02d}", + rl.Vector2(x, y), 22, 0, GRAY) + y += 35 - # Separator - rl.draw_rectangle(x, y, w, 2, rl.Color(60, 60, 60, 255)) + # Shift Score Progress Bar with comparison + y = self._draw_score_bar(x, y, w, "Shift Score", self._shift_score, self._avg_shift_score) y += 15 - # Stats sections - y = self._draw_stat_section(x, y, w, "Stalls", self._session_data.get('stall_count', 0), target=0, lower_better=True) - y = self._draw_stat_section(x, y, w, "Engine Lugs", self._session_data.get('lug_count', 0), target=0, lower_better=True) + # Stats in a card + rl.draw_rectangle_rounded(rl.Rectangle(x, y, w, 180), 0.02, 10, BG_CARD) + card_x = x + 15 + card_y = y + 12 + + # Jackets section (stalls + lugs) + stalls = self._session_data.get('stall_count', 0) + lugs = self._session_data.get('lug_count', 0) + jackets_text = "Jackets:" if (stalls > 0 or lugs > 0) else "No Jackets!" + jackets_color = RED if stalls > 0 else (YELLOW if lugs > 0 else GREEN) + rl.draw_text_ex(font_medium, jackets_text, rl.Vector2(card_x, card_y), 24, 0, jackets_color) + card_y += 30 + + card_y = self._draw_mini_stat(card_x, card_y, w - 30, "Stalls", stalls, 0, True) + card_y = self._draw_mini_stat(card_x, card_y, w - 30, "Lugs", lugs, 0, True) + + # Waddle section (launches + shifts) + card_y += 8 + rl.draw_text_ex(font_medium, "Waddle Stats:", rl.Vector2(card_x, card_y), 24, 0, WHITE) + card_y += 30 - # Launches launch_total = self._session_data.get('launch_count', 0) launch_good = self._session_data.get('launch_good', 0) - launch_stalled = self._session_data.get('launch_stalled', 0) - if launch_total > 0: - y = self._draw_stat_section(x, y, w, "Good Launches", f"{launch_good}/{launch_total}", - target=launch_total, current=launch_good) - if launch_stalled > 0: - y = self._draw_stat_section(x, y, w, "Stalled Launches", launch_stalled, target=0, lower_better=True) - - # Upshifts upshift_total = self._session_data.get('upshift_count', 0) upshift_good = self._session_data.get('upshift_good', 0) - if upshift_total > 0: - y = self._draw_stat_section(x, y, w, "Good Upshifts", f"{upshift_good}/{upshift_total}", - target=upshift_total, current=upshift_good) - - # Downshifts downshift_total = self._session_data.get('downshift_count', 0) downshift_good = self._session_data.get('downshift_good', 0) - if downshift_total > 0: - y = self._draw_stat_section(x, y, w, "Good Downshifts", f"{downshift_good}/{downshift_total}", - target=downshift_total, current=downshift_good) - y += 10 + if launch_total > 0: + card_y = self._draw_mini_stat(card_x, card_y, w - 30, "Launches", f"{launch_good}/{launch_total}", launch_total, False, launch_good) + total_shifts = upshift_total + downshift_total + total_good = upshift_good + downshift_good + if total_shifts > 0: + card_y = self._draw_mini_stat(card_x, card_y, w - 30, "Shifts", f"{total_good}/{total_shifts}", total_shifts, False, total_good) + + y += 190 # Encouragement/criticism text encouragement = self._get_encouragement_text() - wrapped = wrap_text(gui_app.font(FontWeight.ROMAN), encouragement, 24, w) + wrapped = wrap_text(font_roman, encouragement, 22, w) for line in wrapped: - rl.draw_text_ex(gui_app.font(FontWeight.ROMAN), line, rl.Vector2(x, y), 24, 0, LIGHT_GRAY) - y += int(24 * FONT_SCALE) + rl.draw_text_ex(font_roman, line, rl.Vector2(x, y), 22, 0, LIGHT_GRAY) + y += 28 # Tap to dismiss hint - hint_text = "Tap to dismiss" - hint_font = gui_app.font(FontWeight.ROMAN) - hint_size = 20 - rl.draw_text_ex(hint_font, hint_text, rl.Vector2(dialog_x + dialog_w // 2 - 50, dialog_y + dialog_h - 35), - hint_size, 0, GRAY) + hint_text = "Tap anywhere to dismiss" + hint_width = rl.measure_text_ex(font_roman, hint_text, 18, 0).x + rl.draw_text_ex(font_roman, hint_text, rl.Vector2(dialog_x + (dialog_w - hint_width) // 2, dialog_y + dialog_h - 30), + 18, 0, GRAY) + + def _draw_score_bar(self, x: int, y: int, w: int, label: str, score: float, avg_score: float) -> int: + """Draw a progress bar showing score vs average""" + font_medium = gui_app.font(FontWeight.MEDIUM) + font_roman = gui_app.font(FontWeight.ROMAN) + + # Label and score + rl.draw_text_ex(font_medium, label, rl.Vector2(x, y), 22, 0, WHITE) + score_text = f"{int(score)}%" + score_color = GREEN if score >= 80 else (YELLOW if score >= 50 else RED) + score_width = rl.measure_text_ex(font_medium, score_text, 22, 0).x + rl.draw_text_ex(font_medium, score_text, rl.Vector2(x + w - score_width, y), 22, 0, score_color) + y += 28 + + # Progress bar background + bar_h = 16 + rl.draw_rectangle_rounded(rl.Rectangle(x, y, w, bar_h), 0.3, 10, rl.Color(60, 60, 60, 255)) + + # Progress bar fill + fill_w = int((score / 100) * w) + if fill_w > 0: + rl.draw_rectangle_rounded(rl.Rectangle(x, y, fill_w, bar_h), 0.3, 10, score_color) + + # Average marker line + if avg_score > 0: + avg_x = x + int((avg_score / 100) * w) + rl.draw_rectangle(avg_x - 1, y - 2, 3, bar_h + 4, WHITE) + + y += bar_h + 6 + + # Comparison text + if avg_score > 0: + diff = score - avg_score + if diff > 5: + comp_text = f"Above avg (+{int(diff)})" + comp_color = GREEN + elif diff < -5: + comp_text = f"Below avg ({int(diff)})" + comp_color = RED + else: + comp_text = "Near average" + comp_color = GRAY + rl.draw_text_ex(font_roman, comp_text, rl.Vector2(x, y), 16, 0, comp_color) + rl.draw_text_ex(font_roman, "| = your avg", rl.Vector2(x + w - 80, y), 16, 0, GRAY) + y += 22 + + return y + + def _draw_mini_stat(self, x: int, y: int, w: int, label: str, value, target, lower_better: bool, current=None) -> int: + """Draw a compact stat row""" + font_roman = gui_app.font(FontWeight.ROMAN) + font_size = 20 + + # Determine color + if lower_better: + if isinstance(value, int): + color = GREEN if value == 0 else (YELLOW if value <= 2 else RED) + else: + color = LIGHT_GRAY + else: + if current is not None and target > 0: + ratio = current / target + color = GREEN if ratio >= 0.8 else (YELLOW if ratio >= 0.5 else RED) + else: + color = LIGHT_GRAY + + rl.draw_text_ex(font_roman, label, rl.Vector2(x, y), font_size, 0, LIGHT_GRAY) + value_str = str(value) + value_width = rl.measure_text_ex(font_roman, value_str, font_size, 0).x + rl.draw_text_ex(font_roman, value_str, rl.Vector2(x + w - value_width, y), font_size, 0, color) + + return y + 26 def _draw_stat_section(self, x: int, y: int, w: int, label: str, value, target=None, current=None, lower_better=False) -> int: diff --git a/selfdrive/ui/mici/layouts/settings/manual_stats.py b/selfdrive/ui/mici/layouts/settings/manual_stats.py index ca198761768c69..efb8e747fe66c1 100644 --- a/selfdrive/ui/mici/layouts/settings/manual_stats.py +++ b/selfdrive/ui/mici/layouts/settings/manual_stats.py @@ -72,8 +72,10 @@ def _render(self, rect: rl.Rectangle): rl.Vector2(x, y), 28, 0, GRAY) return - # Overview card - y = self._draw_card(x, y, w, "Overview", [ + # Overall hand rating + hand_rating, hand_color = self._get_overall_hand() + y = self._draw_card(x, y, w, "Your Hand", [ + ("Overall Rating", hand_rating, hand_color), ("Total Drives", str(self._stats.get('total_drives', 0)), WHITE), ("Total Drive Time", self._format_time(self._stats.get('total_drive_time', 0)), WHITE), ("Total Stalls", str(self._stats.get('total_stalls', 0)), self._stall_color(self._stats.get('total_stalls', 0))), @@ -135,6 +137,23 @@ def _render(self, rect: rl.Rectangle): y = self._draw_card(x, y, w, "Recent Trends", trend_items) y += 15 + # Per-gear smoothness chart + gear_counts = self._stats.get('gear_shift_counts', {}) + gear_jerks = self._stats.get('gear_shift_jerk_totals', {}) + if gear_counts and any(gear_counts.values()): + y = self._draw_gear_chart(x, y, w, gear_counts, gear_jerks) + y += 15 + + # Session history charts + session_history = self._stats.get('session_history', []) + if session_history: + y = self._draw_shift_chart(x, y, w, session_history) + y += 15 + y = self._draw_stalls_chart(x, y, w, session_history) + y += 15 + y = self._draw_launch_chart(x, y, w, session_history) + y += 15 + # Encouragement based on progress (with text wrapping) y += 10 encouragement = self._get_encouragement() @@ -144,11 +163,19 @@ def _render(self, rect: rl.Rectangle): y += 30 def _draw_card(self, x: int, y: int, w: int, title: str, items: list) -> int: - """Draw a card with title and stat items""" + """Draw a card with title and stat items, with wrapping for long values""" font_bold = gui_app.font(FontWeight.BOLD) font_medium = gui_app.font(FontWeight.MEDIUM) - card_h = 50 + len(items) * 38 + # Calculate height - check for items that need wrapping + extra_lines = 0 + max_value_width = w - 140 # Leave space for label + for _, value, _ in items: + value_width = rl.measure_text_ex(font_medium, value, 26, 0).x + if value_width > max_value_width: + extra_lines += 1 + + card_h = 50 + len(items) * 38 + extra_lines * 30 rl.draw_rectangle_rounded(rl.Rectangle(x, y, w, card_h), 0.02, 10, BG_CARD) # Title @@ -159,11 +186,282 @@ def _draw_card(self, x: int, y: int, w: int, title: str, items: list) -> int: for label, value, color in items: rl.draw_text_ex(font_medium, label, rl.Vector2(x + 15, y), 26, 0, LIGHT_GRAY) value_width = rl.measure_text_ex(font_medium, value, 26, 0).x - rl.draw_text_ex(font_medium, value, rl.Vector2(x + w - 15 - value_width, y), 26, 0, color) - y += 38 + + # Check if value needs to wrap to next line + if value_width > max_value_width: + # Draw value on next line, left-aligned with indent + y += 30 + rl.draw_text_ex(font_medium, value, rl.Vector2(x + 25, y), 24, 0, color) + y += 38 + else: + # Draw value right-aligned on same line + rl.draw_text_ex(font_medium, value, rl.Vector2(x + w - 15 - value_width, y), 26, 0, color) + y += 38 return y + def _draw_shift_chart(self, x: int, y: int, w: int, sessions: list) -> int: + """Draw a bar chart showing shift score history""" + import datetime + font_bold = gui_app.font(FontWeight.BOLD) + font_small = gui_app.font(FontWeight.ROMAN) + + chart_h = 200 + rl.draw_rectangle_rounded(rl.Rectangle(x, y, w, chart_h), 0.02, 10, BG_CARD) + + # Title + rl.draw_text_ex(font_bold, "Shift Score History", rl.Vector2(x + 15, y + 12), 28, 0, WHITE) + + # Chart area + chart_x = x + 40 + chart_y = y + 50 + chart_w = w - 60 + chart_inner_h = 90 + + # Draw axis + rl.draw_line(chart_x, chart_y + chart_inner_h, chart_x + chart_w, chart_y + chart_inner_h, GRAY) + rl.draw_line(chart_x, chart_y, chart_x, chart_y + chart_inner_h, GRAY) + + # Y-axis labels + rl.draw_text_ex(font_small, "100", rl.Vector2(x + 10, chart_y - 5), 14, 0, GRAY) + rl.draw_text_ex(font_small, "50", rl.Vector2(x + 15, chart_y + chart_inner_h // 2 - 5), 14, 0, GRAY) + rl.draw_text_ex(font_small, "0", rl.Vector2(x + 22, chart_y + chart_inner_h - 5), 14, 0, GRAY) + + display_sessions = sessions[-12:] if len(sessions) > 12 else sessions + if not display_sessions: + return y + chart_h + + bar_spacing = 4 + bar_w = max(8, (chart_w - bar_spacing * len(display_sessions)) // len(display_sessions)) + + for i, session in enumerate(display_sessions): + ups = session.get('upshifts', 0) + ups_good = session.get('upshifts_good', 0) + downs = session.get('downshifts', 0) + downs_good = session.get('downshifts_good', 0) + total = ups + downs + score = ((ups_good + downs_good) / total * 100) if total > 0 else 100 + + bar_h = int((score / 100) * chart_inner_h) + bar_x = chart_x + i * (bar_w + bar_spacing) + bar_y = chart_y + chart_inner_h - bar_h + + color = GREEN if score >= 80 else (YELLOW if score >= 50 else RED) + rl.draw_rectangle(int(bar_x), int(bar_y), int(bar_w), int(bar_h), color) + + # Day label + timestamp = session.get('timestamp', 0) + if timestamp > 0: + dt = datetime.datetime.fromtimestamp(timestamp) + day_x = bar_x + bar_w // 2 - 4 + rl.draw_text_ex(font_small, str(dt.day), rl.Vector2(day_x, chart_y + chart_inner_h + 4), 13, 0, GRAY) + + # Legend + legend_y = chart_y + chart_inner_h + 22 + rl.draw_text_ex(font_small, "Higher = better shifts. Green 80%+, Yellow 50%+, Red <50%", rl.Vector2(chart_x, legend_y), 14, 0, GRAY) + + return y + chart_h + + def _draw_stalls_chart(self, x: int, y: int, w: int, sessions: list) -> int: + """Draw a bar chart showing stalls and lugs per session""" + import datetime + font_bold = gui_app.font(FontWeight.BOLD) + font_small = gui_app.font(FontWeight.ROMAN) + + chart_h = 180 + rl.draw_rectangle_rounded(rl.Rectangle(x, y, w, chart_h), 0.02, 10, BG_CARD) + + # Title + rl.draw_text_ex(font_bold, "Stalls & Lugs (Jackets)", rl.Vector2(x + 15, y + 12), 28, 0, WHITE) + + # Chart area + chart_x = x + 40 + chart_y = y + 50 + chart_w = w - 60 + chart_inner_h = 70 + + # Find max for scaling + display_sessions = sessions[-12:] if len(sessions) > 12 else sessions + max_issues = max((s.get('stalls', 0) + s.get('lugs', 0) for s in display_sessions), default=1) + max_issues = max(max_issues, 5) # Min scale of 5 + + # Draw axis + rl.draw_line(chart_x, chart_y + chart_inner_h, chart_x + chart_w, chart_y + chart_inner_h, GRAY) + rl.draw_line(chart_x, chart_y, chart_x, chart_y + chart_inner_h, GRAY) + + # Y-axis labels + rl.draw_text_ex(font_small, str(max_issues), rl.Vector2(x + 15, chart_y - 5), 14, 0, GRAY) + rl.draw_text_ex(font_small, "0", rl.Vector2(x + 22, chart_y + chart_inner_h - 5), 14, 0, GRAY) + + if not display_sessions: + return y + chart_h + + bar_spacing = 4 + bar_w = max(8, (chart_w - bar_spacing * len(display_sessions)) // len(display_sessions)) + + for i, session in enumerate(display_sessions): + stalls = session.get('stalls', 0) + lugs = session.get('lugs', 0) + bar_x = chart_x + i * (bar_w + bar_spacing) + + # Stacked bar: stalls (red) on bottom, lugs (orange) on top + stall_h = int((stalls / max_issues) * chart_inner_h) + lug_h = int((lugs / max_issues) * chart_inner_h) + + # Lugs (yellow/orange) - bottom + if lug_h > 0: + rl.draw_rectangle(int(bar_x), int(chart_y + chart_inner_h - lug_h), int(bar_w), int(lug_h), YELLOW) + + # Stalls (red) - stacked on top of lugs + if stall_h > 0: + rl.draw_rectangle(int(bar_x), int(chart_y + chart_inner_h - lug_h - stall_h), int(bar_w), int(stall_h), RED) + + # Day label + timestamp = session.get('timestamp', 0) + if timestamp > 0: + dt = datetime.datetime.fromtimestamp(timestamp) + day_x = bar_x + bar_w // 2 - 4 + rl.draw_text_ex(font_small, str(dt.day), rl.Vector2(day_x, chart_y + chart_inner_h + 4), 13, 0, GRAY) + + # Legend + legend_y = chart_y + chart_inner_h + 22 + rl.draw_rectangle(int(chart_x), int(legend_y + 2), 12, 12, RED) + rl.draw_text_ex(font_small, "Stalls", rl.Vector2(chart_x + 16, legend_y), 14, 0, GRAY) + rl.draw_rectangle(int(chart_x + 70), int(legend_y + 2), 12, 12, YELLOW) + rl.draw_text_ex(font_small, "Lugs", rl.Vector2(chart_x + 86, legend_y), 14, 0, GRAY) + rl.draw_text_ex(font_small, "Lower = fewer jackets!", rl.Vector2(chart_x + 140, legend_y), 14, 0, GRAY) + + return y + chart_h + + def _draw_launch_chart(self, x: int, y: int, w: int, sessions: list) -> int: + """Draw a bar chart showing launch success rate""" + import datetime + font_bold = gui_app.font(FontWeight.BOLD) + font_small = gui_app.font(FontWeight.ROMAN) + + chart_h = 180 + rl.draw_rectangle_rounded(rl.Rectangle(x, y, w, chart_h), 0.02, 10, BG_CARD) + + # Title + rl.draw_text_ex(font_bold, "Launch Success (Waddle Rate)", rl.Vector2(x + 15, y + 12), 28, 0, WHITE) + + # Chart area + chart_x = x + 40 + chart_y = y + 50 + chart_w = w - 60 + chart_inner_h = 70 + + # Draw axis + rl.draw_line(chart_x, chart_y + chart_inner_h, chart_x + chart_w, chart_y + chart_inner_h, GRAY) + rl.draw_line(chart_x, chart_y, chart_x, chart_y + chart_inner_h, GRAY) + + # Y-axis labels + rl.draw_text_ex(font_small, "100%", rl.Vector2(x + 5, chart_y - 5), 14, 0, GRAY) + rl.draw_text_ex(font_small, "0%", rl.Vector2(x + 15, chart_y + chart_inner_h - 5), 14, 0, GRAY) + + display_sessions = sessions[-12:] if len(sessions) > 12 else sessions + if not display_sessions: + return y + chart_h + + bar_spacing = 4 + bar_w = max(8, (chart_w - bar_spacing * len(display_sessions)) // len(display_sessions)) + + for i, session in enumerate(display_sessions): + launches = session.get('launches', 0) + launches_good = session.get('launches_good', 0) + bar_x = chart_x + i * (bar_w + bar_spacing) + + if launches > 0: + pct = (launches_good / launches) * 100 + bar_h = int((pct / 100) * chart_inner_h) + bar_y = chart_y + chart_inner_h - bar_h + color = GREEN if pct >= 80 else (YELLOW if pct >= 50 else RED) + rl.draw_rectangle(int(bar_x), int(bar_y), int(bar_w), int(bar_h), color) + else: + # No launches - draw thin gray bar + rl.draw_rectangle(int(bar_x), int(chart_y + chart_inner_h - 2), int(bar_w), 2, GRAY) + + # Day label + timestamp = session.get('timestamp', 0) + if timestamp > 0: + dt = datetime.datetime.fromtimestamp(timestamp) + day_x = bar_x + bar_w // 2 - 4 + rl.draw_text_ex(font_small, str(dt.day), rl.Vector2(day_x, chart_y + chart_inner_h + 4), 13, 0, GRAY) + + # Legend + legend_y = chart_y + chart_inner_h + 22 + rl.draw_text_ex(font_small, "Higher = smoother launches = more waddle, less jacket!", rl.Vector2(chart_x, legend_y), 14, 0, GRAY) + + return y + chart_h + + def _draw_gear_chart(self, x: int, y: int, w: int, gear_counts: dict, gear_jerks: dict) -> int: + """Draw a bar chart showing shift smoothness into each gear (1-6)""" + font_bold = gui_app.font(FontWeight.BOLD) + font_small = gui_app.font(FontWeight.ROMAN) + + chart_h = 180 + rl.draw_rectangle_rounded(rl.Rectangle(x, y, w, chart_h), 0.02, 10, BG_CARD) + + # Title + rl.draw_text_ex(font_bold, "Waddle Smoothness by Gear", rl.Vector2(x + 15, y + 12), 28, 0, WHITE) + + # Chart area + chart_x = x + 50 + chart_y = y + 50 + chart_w = w - 70 + chart_inner_h = 70 + + # Draw axis + rl.draw_line(chart_x, chart_y + chart_inner_h, chart_x + chart_w, chart_y + chart_inner_h, GRAY) + rl.draw_line(chart_x, chart_y, chart_x, chart_y + chart_inner_h, GRAY) + + # Y-axis labels (smoothness score, higher = better) + rl.draw_text_ex(font_small, "Smooth", rl.Vector2(x + 5, chart_y - 2), 12, 0, GREEN) + rl.draw_text_ex(font_small, "Jerky", rl.Vector2(x + 10, chart_y + chart_inner_h - 10), 12, 0, RED) + + # Calculate smoothness scores for each gear (invert jerk - lower jerk = higher score) + bar_spacing = 12 + bar_w = (chart_w - bar_spacing * 5) // 6 + + for gear in range(1, 7): + count = gear_counts.get(gear, gear_counts.get(str(gear), 0)) + jerk_total = gear_jerks.get(gear, gear_jerks.get(str(gear), 0.0)) + + bar_x = chart_x + (gear - 1) * (bar_w + bar_spacing) + + if count > 0: + avg_jerk = jerk_total / count + # Convert jerk to smoothness score (0-100), lower jerk = higher score + # Jerk of 0 = 100, jerk of 5+ = 0 + smoothness = max(0, min(100, 100 - (avg_jerk * 20))) + + bar_h = int((smoothness / 100) * chart_inner_h) + bar_y = chart_y + chart_inner_h - bar_h + + # Color based on smoothness + if smoothness >= 80: + color = GREEN + elif smoothness >= 50: + color = YELLOW + else: + color = RED + + rl.draw_rectangle(int(bar_x), int(bar_y), int(bar_w), int(bar_h), color) + else: + # No data - draw thin gray bar + rl.draw_rectangle(int(bar_x), int(chart_y + chart_inner_h - 2), int(bar_w), 2, GRAY) + + # Gear label + gear_label = str(gear) + label_x = bar_x + bar_w // 2 - 5 + rl.draw_text_ex(font_small, gear_label, rl.Vector2(label_x, chart_y + chart_inner_h + 6), 16, 0, WHITE) + + # Legend + legend_y = chart_y + chart_inner_h + 28 + rl.draw_text_ex(font_small, "Green = waddle smooth, Red = jerky jackets. Practice weak gears!", rl.Vector2(x + 15, legend_y), 14, 0, GRAY) + + return y + chart_h + def _measure_content_height(self, rect: rl.Rectangle) -> int: """Measure total content height for scrolling""" y = 20 + 60 # Title @@ -171,14 +469,23 @@ def _measure_content_height(self, rect: rl.Rectangle) -> int: if not self._stats or self._stats.get('total_drives', 0) == 0: return y + 40 - # Overview card - y += 50 + 4 * 38 + 15 + # Overview card (now has 5 items with hand rating, +30 for potential wrap) + y += 50 + 5 * 38 + 30 + 15 # Shift card y += 50 + 4 * 38 + 15 # Launch card y += 50 + 3 * 38 + 15 # Trend card (estimate) y += 50 + 3 * 38 + 15 + # Gear chart + if self._stats.get('gear_shift_counts'): + y += 180 + 15 + + # Charts (3 charts) + if self._stats.get('session_history'): + y += 200 + 15 # Shift score chart + y += 180 + 15 # Stalls/lugs chart + y += 180 + 15 # Launch chart # Encouragement (estimate 2-3 lines wrapped) y += 100 @@ -240,27 +547,87 @@ def _trend_text(self, trend: float, lower_better: bool) -> tuple[str, rl.Color]: return "Improving!", GREEN return "Getting worse", RED + def _get_overall_hand(self) -> tuple[str, rl.Color]: + """Calculate overall poker hand rating based on all stats""" + total_drives = self._stats.get('total_drives', 0) + if total_drives == 0: + return "No Cards Yet", GRAY + + total_stalls = self._stats.get('total_stalls', 0) + total_shifts = self._stats.get('total_upshifts', 0) + self._stats.get('total_downshifts', 0) + good_shifts = self._stats.get('upshifts_good', self._stats.get('total_upshifts_good', 0)) + \ + self._stats.get('downshifts_good', self._stats.get('total_downshifts_good', 0)) + + stall_rate = total_stalls / total_drives + shift_pct = (good_shifts / total_shifts * 100) if total_shifts > 0 else 100 + + # Calculate overall score + score = shift_pct - (stall_rate * 10) + + # Recent improvement bonus + recent_scores = self._stats.get('recent_shift_scores', []) + if len(recent_scores) >= 3: + if recent_scores[-1] > recent_scores[0]: + score += 5 # Bonus for improving + + if score >= 98 and stall_rate == 0: + return "Royal Flush - Waddle is driving! Kacper threw his glasses!", GREEN + elif score >= 95 and stall_rate == 0: + return "Royal Flush - Porch-worthy waddle! KP earned!", GREEN + elif score >= 90: + return "Straight Flush - Elite waddle, CCM vibes!", GREEN + elif score >= 85: + return "Four of a Kind - Priest-approved waddle!", GREEN + elif score >= 80: + return "Full House - Solid waddle, not SS!", GREEN + elif score >= 70: + return "Flush - Good waddle, almost KP", YELLOW + elif score >= 60: + return "Straight - Improving, not SS yet", YELLOW + elif score >= 50: + return "Three of a Kind - Getting there, shake off jackets", YELLOW + elif score >= 40: + return "Two Pair - Jackets territory", YELLOW + elif score >= 30: + return "One Pair - Jacketed, huge oof", RED + else: + return "High Card - SS! Full jackets!", RED + def _get_encouragement(self) -> str: """Get encouragement based on overall progress""" total_drives = self._stats.get('total_drives', 0) total_stalls = self._stats.get('total_stalls', 0) recent_stalls = self._stats.get('recent_stall_rates', []) + recent_scores = self._stats.get('recent_shift_scores', []) if total_drives == 0: - return "Start driving to see your stats!" + return "Start driving to see your stats! Time to earn your first waddle KP." stall_rate = total_stalls / total_drives if total_drives > 0 else 0 + # Check for improvement + improving = False + if len(recent_scores) >= 3: + if recent_scores[-1] > recent_scores[0] + 5: + improving = True + if len(recent_stalls) >= 3: recent_avg = sum(recent_stalls[-3:]) / 3 if recent_avg == 0: - return "No stalls in recent drives - you're getting the hang of it!" + # Check for crazy good performance + if len(recent_scores) >= 3 and all(s >= 95 for s in recent_scores[-3:]): + return "3 drives 95%+ NO stalls?! Waddle is driving! Kacper threw his glasses!" + if improving: + return "No stalls AND improving? Waddle energy! QG to KP!" + return "No stalls recent - waddle game strong! Not SS, priest-approved!" elif recent_avg < stall_rate: - return "Your recent drives are better than average - keep it up!" + return "Recent drives better than avg - shedding jackets, channeling waddle!" if stall_rate < 0.5: - return "Less than 1 stall per 2 drives on average - nice work!" + if improving: + return "< 1 stall per 2 drives AND improving! Porch-worthy waddle progress!" + return "< 1 stall per 2 drives - solid waddle vibes, not SS!" elif stall_rate < 1: - return "About 1 stall per drive - you're learning fast!" + return "~1 stall per drive - de-jacketing in progress!" else: - return "Keep practicing! Everyone stalls when learning manual." + return "Keep at it! Even the best got jacketed at first. QG to KP!" diff --git a/selfdrive/ui/mici/layouts/settings/settings.py b/selfdrive/ui/mici/layouts/settings/settings.py index fc4ca77874537b..5523190659900c 100644 --- a/selfdrive/ui/mici/layouts/settings/settings.py +++ b/selfdrive/ui/mici/layouts/settings/settings.py @@ -54,8 +54,8 @@ def __init__(self): manual_stats_btn.set_click_callback(lambda: self._set_current_panel(PanelType.MANUAL_STATS)) self._scroller = Scroller([ + manual_stats_btn, # MT Stats first! toggles_btn, - manual_stats_btn, # MT Stats right after Toggles network_btn, device_btn, PairBigButton(), From 28086684808c978cd9bea95f3115aba4a628ef0a Mon Sep 17 00:00:00 2001 From: Shane Smiskol Date: Sat, 24 Jan 2026 22:16:20 -0800 Subject: [PATCH 05/15] show summary dialog --- opendbc_repo | 2 +- .../ui/mici/layouts/manual_drive_summary.py | 126 ++++++++---------- .../ui/mici/layouts/settings/manual_stats.py | 56 +++++--- 3 files changed, 96 insertions(+), 88 deletions(-) diff --git a/opendbc_repo b/opendbc_repo index 9ee44cf28b9beb..6b8347257e11fc 160000 --- a/opendbc_repo +++ b/opendbc_repo @@ -1 +1 @@ -Subproject commit 9ee44cf28b9bebfec9e4ec30af4e0f232f329b6a +Subproject commit 6b8347257e11fc5b95538027bf00d845052057b9 diff --git a/selfdrive/ui/mici/layouts/manual_drive_summary.py b/selfdrive/ui/mici/layouts/manual_drive_summary.py index 62c5a82c0b8edf..37119e1a87fe80 100644 --- a/selfdrive/ui/mici/layouts/manual_drive_summary.py +++ b/selfdrive/ui/mici/layouts/manual_drive_summary.py @@ -7,14 +7,14 @@ """ import json -import time import pyray as rl from typing import Optional, Callable from openpilot.common.params import Params from openpilot.system.ui.lib.application import gui_app, FontWeight, FONT_SCALE +from openpilot.system.ui.lib.scroll_panel2 import GuiScrollPanel2 from openpilot.system.ui.lib.wrap_text import wrap_text -from openpilot.system.ui.widgets import Widget +from openpilot.system.ui.widgets import NavWidget # Colors @@ -31,7 +31,7 @@ # Poker hand names HAND_NAMES = { "A": "Aces", - "K": "Kings", + "K": "Kings", "Q": "Queens", "J": "Jacks", "10": "10s" @@ -46,25 +46,27 @@ } -class ManualDriveSummaryDialog(Widget): +class ManualDriveSummaryDialog(NavWidget): """Modal dialog showing end-of-drive manual transmission stats""" def __init__(self, dismiss_callback: Optional[Callable] = None): super().__init__() self._params = Params() - self._dismiss_callback = dismiss_callback + self._scroll_panel = GuiScrollPanel2(horizontal=False) self._session_data: Optional[dict] = None self._historical_data: Optional[dict] = None self._overall_grade: str = "good" # good, ok, poor self._card_rank: str = "10" # Poker card rank: 10, J, Q, K, A self._shift_score: float = 0.0 self._avg_shift_score: float = 0.0 - self._show_time: float = 0.0 - self._auto_dismiss_after: float = 30.0 # Auto dismiss after 30 seconds + # Load data immediately since show_event may not be called for modals + self._load_session() + self._load_historical() + # Set back callback to dismiss modal + self.set_back_callback(lambda: gui_app.set_modal_overlay(None)) def show_event(self): super().show_event() - self._show_time = time.monotonic() self._load_session() self._load_historical() @@ -233,86 +235,77 @@ def _get_encouragement_text(self) -> str: return " ".join(messages) - def _handle_mouse_release(self, _): - """Dismiss on tap""" - if self._dismiss_callback: - self._dismiss_callback() - gui_app.dismiss_modal() + def _measure_content_height(self) -> int: + """Calculate total content height for scrolling""" + font_roman = gui_app.font(FontWeight.ROMAN) + h = 0 + h += 50 # Header + h += 38 # Card rank + h += 35 # Duration + h += 75 # Shift score bar + h += 195 # Stats card + # Encouragement text (estimate) + encouragement = self._get_encouragement_text() + wrapped = wrap_text(font_roman, encouragement, 22, 500) + h += len(wrapped) * 28 + 20 + return h def _render(self, rect: rl.Rectangle): - if not self._session_data: - # Auto-dismiss if no data - if self._dismiss_callback: - self._dismiss_callback() - gui_app.dismiss_modal() - return + # Content area with scrolling + content_rect = rl.Rectangle(rect.x + 10, rect.y + 10, rect.width - 20, rect.height - 20) + content_height = self._measure_content_height() + scroll_offset = round(self._scroll_panel.update(content_rect, content_height)) - # Auto-dismiss after timeout - if time.monotonic() - self._show_time > self._auto_dismiss_after: - if self._dismiss_callback: - self._dismiss_callback() - gui_app.dismiss_modal() - return - - # Draw semi-transparent background - rl.draw_rectangle(0, 0, gui_app.width, gui_app.height, rl.Color(0, 0, 0, 180)) - - # Dialog dimensions - dialog_w = min(520, gui_app.width - 40) - dialog_h = min(680, gui_app.height - 40) - dialog_x = (gui_app.width - dialog_w) // 2 - dialog_y = (gui_app.height - dialog_h) // 2 - - # Draw dialog background - rl.draw_rectangle_rounded( - rl.Rectangle(dialog_x, dialog_y, dialog_w, dialog_h), - 0.03, 10, BG_COLOR - ) - - # Content area - x = dialog_x + 25 - y = dialog_y + 20 - w = dialog_w - 50 + x = int(content_rect.x) + 20 # Padding on left + y = int(content_rect.y) + scroll_offset + w = int(content_rect.width) - 40 # Padding on both sides font_bold = gui_app.font(FontWeight.BOLD) font_medium = gui_app.font(FontWeight.MEDIUM) font_roman = gui_app.font(FontWeight.ROMAN) + # Enable scissor mode to clip content + rl.begin_scissor_mode(int(content_rect.x), int(content_rect.y), int(content_rect.width), int(content_rect.height)) + + # Top section card background (header, hand, duration, score bar) + top_card_h = 200 + rl.draw_rectangle_rounded(rl.Rectangle(x, y, w, top_card_h), 0.02, 10, BG_CARD) + # Header header_text, header_color = self._get_header_text() - rl.draw_text_ex(font_bold, header_text, rl.Vector2(x, y), 44, 0, header_color) - y += 50 + rl.draw_text_ex(font_bold, header_text, rl.Vector2(x + 15, y + 12), 44, 0, header_color) + y += 58 # Card rank display - poker hand style with subtitle card_color = GREEN if self._card_rank in ("A", "K") else (YELLOW if self._card_rank in ("Q", "J") else RED) card_text = f"Your hand: {HAND_NAMES[self._card_rank]}" - rl.draw_text_ex(font_medium, card_text, rl.Vector2(x, y), 28, 0, card_color) + rl.draw_text_ex(font_medium, card_text, rl.Vector2(x + 15, y), 28, 0, card_color) # Subtitle subtitle = HAND_SUBTITLES[self._card_rank] subtitle_width = rl.measure_text_ex(font_roman, subtitle, 20, 0).x - rl.draw_text_ex(font_roman, subtitle, rl.Vector2(x + w - subtitle_width, y + 4), 20, 0, card_color) + rl.draw_text_ex(font_roman, subtitle, rl.Vector2(x + w - subtitle_width - 35, y + 4), 20, 0, card_color) y += 38 # Duration - duration = self._session_data.get('duration', 0) + duration = self._session_data.get('duration', 0) if self._session_data else 0 duration_min = int(duration // 60) duration_sec = int(duration % 60) rl.draw_text_ex(font_roman, f"Drive: {duration_min}:{duration_sec:02d}", - rl.Vector2(x, y), 22, 0, GRAY) + rl.Vector2(x + 15, y), 22, 0, GRAY) y += 35 # Shift Score Progress Bar with comparison - y = self._draw_score_bar(x, y, w, "Shift Score", self._shift_score, self._avg_shift_score) + y = self._draw_score_bar(x + 15, y, w - 30, "Shift Score", self._shift_score, self._avg_shift_score) y += 15 # Stats in a card - rl.draw_rectangle_rounded(rl.Rectangle(x, y, w, 180), 0.02, 10, BG_CARD) + rl.draw_rectangle_rounded(rl.Rectangle(x, y, w, 190), 0.02, 10, BG_CARD) card_x = x + 15 card_y = y + 12 # Jackets section (stalls + lugs) - stalls = self._session_data.get('stall_count', 0) - lugs = self._session_data.get('lug_count', 0) + stalls = self._session_data.get('stall_count', 0) if self._session_data else 0 + lugs = self._session_data.get('lug_count', 0) if self._session_data else 0 jackets_text = "Jackets:" if (stalls > 0 or lugs > 0) else "No Jackets!" jackets_color = RED if stalls > 0 else (YELLOW if lugs > 0 else GREEN) rl.draw_text_ex(font_medium, jackets_text, rl.Vector2(card_x, card_y), 24, 0, jackets_color) @@ -326,21 +319,22 @@ def _render(self, rect: rl.Rectangle): rl.draw_text_ex(font_medium, "Waddle Stats:", rl.Vector2(card_x, card_y), 24, 0, WHITE) card_y += 30 - launch_total = self._session_data.get('launch_count', 0) - launch_good = self._session_data.get('launch_good', 0) - upshift_total = self._session_data.get('upshift_count', 0) - upshift_good = self._session_data.get('upshift_good', 0) - downshift_total = self._session_data.get('downshift_count', 0) - downshift_good = self._session_data.get('downshift_good', 0) + upshift_total = self._session_data.get('upshift_count', 0) if self._session_data else 0 + upshift_good = self._session_data.get('upshift_good', 0) if self._session_data else 0 + downshift_total = self._session_data.get('downshift_count', 0) if self._session_data else 0 + downshift_good = self._session_data.get('downshift_good', 0) if self._session_data else 0 + launch_total = self._session_data.get('launch_count', 0) if self._session_data else 0 + launch_good = self._session_data.get('launch_good', 0) if self._session_data else 0 if launch_total > 0: card_y = self._draw_mini_stat(card_x, card_y, w - 30, "Launches", f"{launch_good}/{launch_total}", launch_total, False, launch_good) + total_shifts = upshift_total + downshift_total total_good = upshift_good + downshift_good if total_shifts > 0: card_y = self._draw_mini_stat(card_x, card_y, w - 30, "Shifts", f"{total_good}/{total_shifts}", total_shifts, False, total_good) - y += 190 + y += 200 # Encouragement/criticism text encouragement = self._get_encouragement_text() @@ -349,11 +343,9 @@ def _render(self, rect: rl.Rectangle): rl.draw_text_ex(font_roman, line, rl.Vector2(x, y), 22, 0, LIGHT_GRAY) y += 28 - # Tap to dismiss hint - hint_text = "Tap anywhere to dismiss" - hint_width = rl.measure_text_ex(font_roman, hint_text, 18, 0).x - rl.draw_text_ex(font_roman, hint_text, rl.Vector2(dialog_x + (dialog_w - hint_width) // 2, dialog_y + dialog_h - 30), - 18, 0, GRAY) + rl.end_scissor_mode() + + return -1 # Keep showing dialog def _draw_score_bar(self, x: int, y: int, w: int, label: str, score: float, avg_score: float) -> int: """Draw a progress bar showing score vs average""" diff --git a/selfdrive/ui/mici/layouts/settings/manual_stats.py b/selfdrive/ui/mici/layouts/settings/manual_stats.py index efb8e747fe66c1..afc7d1e3c9abfd 100644 --- a/selfdrive/ui/mici/layouts/settings/manual_stats.py +++ b/selfdrive/ui/mici/layouts/settings/manual_stats.py @@ -12,6 +12,7 @@ from openpilot.system.ui.lib.scroll_panel2 import GuiScrollPanel2 from openpilot.system.ui.lib.wrap_text import wrap_text from openpilot.system.ui.widgets import Widget, NavWidget +from openpilot.selfdrive.ui.mici.layouts.manual_drive_summary import ManualDriveSummaryDialog # Colors @@ -67,6 +68,16 @@ def _render(self, rect: rl.Rectangle): rl.draw_text_ex(font_bold, "Manual Driving Stats", rl.Vector2(x, y), 48, 0, WHITE) y += 60 + # View Last Drive button + btn_w, btn_h = 340, 65 + btn_rect = rl.Rectangle(x, y, btn_w, btn_h) + btn_color = rl.Color(60, 60, 60, 255) if not rl.check_collision_point_rec(rl.get_mouse_position(), btn_rect) else rl.Color(80, 80, 80, 255) + rl.draw_rectangle_rounded(btn_rect, 0.3, 10, btn_color) + rl.draw_text_ex(font_medium, "View Last Drive Summary", rl.Vector2(x + 20, y + 18), 26, 0, WHITE) + if rl.is_mouse_button_released(rl.MouseButton.MOUSE_BUTTON_LEFT) and rl.check_collision_point_rec(rl.get_mouse_position(), btn_rect): + gui_app.set_modal_overlay(ManualDriveSummaryDialog()) + y += btn_h + 25 + if not self._stats or self._stats.get('total_drives', 0) == 0: rl.draw_text_ex(font_roman, "No driving data yet. Get out there and practice!", rl.Vector2(x, y), 28, 0, GRAY) @@ -86,8 +97,8 @@ def _render(self, rect: rl.Rectangle): # Shift quality card total_up = self._stats.get('total_upshifts', 0) total_down = self._stats.get('total_downshifts', 0) - up_good = self._stats.get('total_upshifts_good', 0) - down_good = self._stats.get('total_downshifts_good', 0) + up_good = self._stats.get('upshifts_good', 0) + down_good = self._stats.get('downshifts_good', 0) up_pct = f"{int(up_good / total_up * 100)}%" if total_up > 0 else "N/A" down_pct = f"{int(down_good / total_down * 100)}%" if total_down > 0 else "N/A" @@ -102,8 +113,8 @@ def _render(self, rect: rl.Rectangle): # Launch quality card total_launches = self._stats.get('total_launches', 0) - good_launches = self._stats.get('total_launches_good', 0) - stalled_launches = self._stats.get('total_launches_stalled', 0) + good_launches = self._stats.get('launches_good', 0) + stalled_launches = self._stats.get('launches_stalled', 0) launch_pct = f"{int(good_launches / total_launches * 100)}%" if total_launches > 0 else "N/A" @@ -169,13 +180,13 @@ def _draw_card(self, x: int, y: int, w: int, title: str, items: list) -> int: # Calculate height - check for items that need wrapping extra_lines = 0 - max_value_width = w - 140 # Leave space for label + max_value_width = w - 220 # Leave space for label, trigger wrap earlier for _, value, _ in items: - value_width = rl.measure_text_ex(font_medium, value, 26, 0).x + value_width = rl.measure_text_ex(font_medium, value, 24, 0).x if value_width > max_value_width: extra_lines += 1 - card_h = 50 + len(items) * 38 + extra_lines * 30 + card_h = 50 + len(items) * 38 + extra_lines * 32 rl.draw_rectangle_rounded(rl.Rectangle(x, y, w, card_h), 0.02, 10, BG_CARD) # Title @@ -184,18 +195,23 @@ def _draw_card(self, x: int, y: int, w: int, title: str, items: list) -> int: # Items for label, value, color in items: - rl.draw_text_ex(font_medium, label, rl.Vector2(x + 15, y), 26, 0, LIGHT_GRAY) - value_width = rl.measure_text_ex(font_medium, value, 26, 0).x + value_width = rl.measure_text_ex(font_medium, value, 24, 0).x - # Check if value needs to wrap to next line + # Check if value needs to wrap to next line (below label) if value_width > max_value_width: - # Draw value on next line, left-aligned with indent - y += 30 - rl.draw_text_ex(font_medium, value, rl.Vector2(x + 25, y), 24, 0, color) - y += 38 + # Draw label + rl.draw_text_ex(font_medium, label, rl.Vector2(x + 15, y), 26, 0, LIGHT_GRAY) + y += 32 + # Draw value on next line, wrapped if needed + wrapped = wrap_text(font_medium, value, 22, w - 40) + for line in wrapped: + rl.draw_text_ex(font_medium, line, rl.Vector2(x + 25, y), 22, 0, color) + y += 26 + y += 6 else: - # Draw value right-aligned on same line - rl.draw_text_ex(font_medium, value, rl.Vector2(x + w - 15 - value_width, y), 26, 0, color) + # Draw label and value on same line + rl.draw_text_ex(font_medium, label, rl.Vector2(x + 15, y), 26, 0, LIGHT_GRAY) + rl.draw_text_ex(font_medium, value, rl.Vector2(x + w - 15 - value_width, y), 24, 0, color) y += 38 return y @@ -465,12 +481,13 @@ def _draw_gear_chart(self, x: int, y: int, w: int, gear_counts: dict, gear_jerks def _measure_content_height(self, rect: rl.Rectangle) -> int: """Measure total content height for scrolling""" y = 20 + 60 # Title + y += 90 # View Last Drive button (65 + 25) if not self._stats or self._stats.get('total_drives', 0) == 0: return y + 40 - # Overview card (now has 5 items with hand rating, +30 for potential wrap) - y += 50 + 5 * 38 + 30 + 15 + # Overview card (now has 5 items with hand rating, +60 for potential wrapped lines) + y += 50 + 5 * 38 + 60 + 15 # Shift card y += 50 + 4 * 38 + 15 # Launch card @@ -555,8 +572,7 @@ def _get_overall_hand(self) -> tuple[str, rl.Color]: total_stalls = self._stats.get('total_stalls', 0) total_shifts = self._stats.get('total_upshifts', 0) + self._stats.get('total_downshifts', 0) - good_shifts = self._stats.get('upshifts_good', self._stats.get('total_upshifts_good', 0)) + \ - self._stats.get('downshifts_good', self._stats.get('total_downshifts_good', 0)) + good_shifts = self._stats.get('upshifts_good', 0) + self._stats.get('downshifts_good', 0) stall_rate = total_stalls / total_drives shift_pct = (good_shifts / total_shifts * 100) if total_shifts > 0 else 100 From 5e68a1dcff17969a4c0fdd2605269bf363f683a7 Mon Sep 17 00:00:00 2001 From: Shane Smiskol Date: Sat, 24 Jan 2026 22:44:49 -0800 Subject: [PATCH 06/15] fix --- opendbc_repo | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/opendbc_repo b/opendbc_repo index 6b8347257e11fc..fbeaba6b9cf36c 160000 --- a/opendbc_repo +++ b/opendbc_repo @@ -1 +1 @@ -Subproject commit 6b8347257e11fc5b95538027bf00d845052057b9 +Subproject commit fbeaba6b9cf36c076af9ae8877790875d4b232d0 From 7d3468c668867bdaef21bf7eddb1ec91454a4cb0 Mon Sep 17 00:00:00 2001 From: Shane Smiskol Date: Sat, 24 Jan 2026 22:59:24 -0800 Subject: [PATCH 07/15] even opus doesn't know about monotonic --- opendbc_repo | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/opendbc_repo b/opendbc_repo index fbeaba6b9cf36c..71c6984de0a92b 160000 --- a/opendbc_repo +++ b/opendbc_repo @@ -1 +1 @@ -Subproject commit fbeaba6b9cf36c076af9ae8877790875d4b232d0 +Subproject commit 71c6984de0a92bd8addc83b892d0c950c7028d6c From 6cf41e554b593a66b948ba1bfd6e9b034d00011b Mon Sep 17 00:00:00 2001 From: Shane Smiskol Date: Sat, 24 Jan 2026 23:31:31 -0800 Subject: [PATCH 08/15] log --- opendbc_repo | 2 +- selfdrive/test/process_replay/process_replay.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/opendbc_repo b/opendbc_repo index 71c6984de0a92b..0d7d65ed5c5510 160000 --- a/opendbc_repo +++ b/opendbc_repo @@ -1 +1 @@ -Subproject commit 71c6984de0a92bd8addc83b892d0c950c7028d6c +Subproject commit 0d7d65ed5c5510f1d16bca0480a19a48bf4f7999 diff --git a/selfdrive/test/process_replay/process_replay.py b/selfdrive/test/process_replay/process_replay.py index 8af72e5f4e7c94..092fbc14f42d0f 100755 --- a/selfdrive/test/process_replay/process_replay.py +++ b/selfdrive/test/process_replay/process_replay.py @@ -743,7 +743,7 @@ def generate_params_config(lr=None, CP=None, fingerprint=None, custom_params=Non def generate_environ_config(CP=None, fingerprint=None, log_dir=None) -> dict[str, Any]: environ_dict = {} - environ_dict["PARAMS_ROOT"] = f"{Paths.shm_path()}/params" + # environ_dict["PARAMS_ROOT"] = f"{Paths.shm_path()}/params" if log_dir is not None: environ_dict["LOG_ROOT"] = log_dir From 6797179a9886bae2360d6012641270a5565b1cf9 Mon Sep 17 00:00:00 2001 From: Shane Smiskol Date: Sat, 24 Jan 2026 23:44:17 -0800 Subject: [PATCH 09/15] fix load --- opendbc_repo | 2 +- .../ui/mici/onroad/manual_stats_widget.py | 22 ++++++++----------- 2 files changed, 10 insertions(+), 14 deletions(-) diff --git a/opendbc_repo b/opendbc_repo index 0d7d65ed5c5510..0bdceedbac9143 160000 --- a/opendbc_repo +++ b/opendbc_repo @@ -1 +1 @@ -Subproject commit 0d7d65ed5c5510f1d16bca0480a19a48bf4f7999 +Subproject commit 0bdceedbac914364653c5d707ce53319863c0acd diff --git a/selfdrive/ui/mici/onroad/manual_stats_widget.py b/selfdrive/ui/mici/onroad/manual_stats_widget.py index 93ad4d6e397f9b..83ee2f20e78175 100644 --- a/selfdrive/ui/mici/onroad/manual_stats_widget.py +++ b/selfdrive/ui/mici/onroad/manual_stats_widget.py @@ -8,6 +8,7 @@ import pyray as rl from openpilot.common.params import Params +from openpilot.selfdrive.ui.ui_state import ui_state from openpilot.system.ui.lib.application import gui_app, FontWeight from openpilot.system.ui.widgets import Widget @@ -45,8 +46,8 @@ def _render(self, rect: rl.Rectangle): self._update_counter = 0 self._load_stats() - if not self._stats: - return + # Get live data from CarState (always available, doesn't need param) + cs = ui_state.sm['carState'] if ui_state.sm.valid['carState'] else None # Widget dimensions w = 140 @@ -62,14 +63,13 @@ def _render(self, rect: rl.Rectangle): px = x + 10 py = y + 8 - # Current gear (big) - gear = self._stats.get('gear', 0) + # Current gear from CarState (big) - always show this + gear = cs.gearActual if cs else 0 gear_text = str(gear) if gear > 0 else "N" rl.draw_text_ex(font_bold, gear_text, rl.Vector2(px, py), 42, 0, WHITE) - # Shift suggestion next to gear + # Shift suggestion next to gear (from param stats) suggestion = self._stats.get('shift_suggestion', 'ok') - reason = self._stats.get('shift_reason', '') if suggestion == 'upshift': rl.draw_text_ex(font_bold, "↑", rl.Vector2(px + 35, py + 5), 36, 0, GREEN) elif suggestion == 'downshift': @@ -87,8 +87,8 @@ def _render(self, rect: rl.Rectangle): rl.draw_text_ex(font, f"Stalls: {stalls}", rl.Vector2(px, py), font_size, 0, color) py += line_h - # Lugging indicator - is_lugging = self._stats.get('is_lugging', False) + # Lugging indicator - use CarState.isLugging for real-time, param for count + is_lugging = cs.isLugging if cs else False lugs = self._stats.get('lugs', 0) if is_lugging: rl.draw_text_ex(font, "LUGGING!", rl.Vector2(px, py), font_size, 0, RED) @@ -110,10 +110,6 @@ def _render(self, rect: rl.Rectangle): def _load_stats(self): """Load current session stats""" try: - data = self._params.get("ManualDriveLiveStats") - if data: - self._stats = json.loads(data) - else: - self._stats = {} + self._stats = self._params.get("ManualDriveLiveStats") except Exception: self._stats = {} From c9136daadd1db2933499795dcdb9e48c0bf3e38d Mon Sep 17 00:00:00 2001 From: Shane Smiskol Date: Sun, 25 Jan 2026 00:07:18 -0800 Subject: [PATCH 10/15] rev matching --- opendbc_repo | 2 +- selfdrive/assets/fonts/process.py | 2 +- .../ui/mici/onroad/manual_stats_widget.py | 177 +++++++++++++++--- 3 files changed, 149 insertions(+), 32 deletions(-) diff --git a/opendbc_repo b/opendbc_repo index 0bdceedbac9143..4ab73ae3d4de31 160000 --- a/opendbc_repo +++ b/opendbc_repo @@ -1 +1 @@ -Subproject commit 0bdceedbac914364653c5d707ce53319863c0acd +Subproject commit 4ab73ae3d4de3195cf4b9848ceb54d51e9541d0d diff --git a/selfdrive/assets/fonts/process.py b/selfdrive/assets/fonts/process.py index ddc8b3a8682c23..a998fd2a69210e 100755 --- a/selfdrive/assets/fonts/process.py +++ b/selfdrive/assets/fonts/process.py @@ -10,7 +10,7 @@ LANGUAGES_FILE = TRANSLATIONS_DIR / "languages.json" GLYPH_PADDING = 6 -EXTRA_CHARS = "–‑✓×°§•X⚙✕◀▶✔⌫⇧␣○●↳çêüñ–‑✓×°§•€£¥" +EXTRA_CHARS = "–‑✓×°§•X⚙✕◀▶✔⌫⇧␣○●↳çêüñ–‑✓×°§•€£¥↑↓✗" UNIFONT_LANGUAGES = {"ar", "th", "zh-CHT", "zh-CHS", "ko", "ja"} diff --git a/selfdrive/ui/mici/onroad/manual_stats_widget.py b/selfdrive/ui/mici/onroad/manual_stats_widget.py index 83ee2f20e78175..4969be5d1d7162 100644 --- a/selfdrive/ui/mici/onroad/manual_stats_widget.py +++ b/selfdrive/ui/mici/onroad/manual_stats_widget.py @@ -1,7 +1,8 @@ """ Live Manual Stats Widget -Small onroad overlay showing current drive statistics and shift suggestions. +Small onroad overlay showing current drive statistics, RPM meter with rev-match helper, +shift grade feedback, and launch progress. """ import json @@ -17,14 +18,33 @@ GREEN = rl.Color(46, 204, 113, 220) YELLOW = rl.Color(241, 196, 15, 220) RED = rl.Color(231, 76, 60, 220) +ORANGE = rl.Color(230, 126, 34, 220) CYAN = rl.Color(52, 152, 219, 220) WHITE = rl.Color(255, 255, 255, 220) GRAY = rl.Color(150, 150, 150, 200) BG_COLOR = rl.Color(0, 0, 0, 160) +# RPM zones for BRZ (7500 redline) +RPM_REDLINE = 7500 +RPM_ECONOMY_MAX = 2500 +RPM_POWER_MIN = 4000 +RPM_DANGER_MIN = 6500 + +# 2024 BRZ gear ratios for rev-match calculation +BRZ_GEAR_RATIOS = {1: 3.626, 2: 2.188, 3: 1.541, 4: 1.213, 5: 1.000, 6: 0.767} +BRZ_FINAL_DRIVE = 4.10 +BRZ_TIRE_CIRCUMFERENCE = 1.977 + + +def rpm_for_speed_and_gear(speed_ms: float, gear: int) -> float: + """Calculate expected RPM for a given speed and gear""" + if gear not in BRZ_GEAR_RATIOS or speed_ms <= 0: + return 0.0 + return (speed_ms * BRZ_FINAL_DRIVE * BRZ_GEAR_RATIOS[gear] * 60) / BRZ_TIRE_CIRCUMFERENCE + class ManualStatsWidget(Widget): - """Small widget showing live manual driving stats and shift suggestions""" + """Widget showing live manual driving stats, RPM meter, and feedback""" def __init__(self): super().__init__() @@ -32,6 +52,13 @@ def __init__(self): self._visible = False self._stats: dict = {} self._update_counter = 0 + # Shift grade flash state + self._last_shift_grade = 0 + self._shift_flash_frames = 0 + self._flash_grade = 0 # The grade to display during flash + # Track gear before clutch for rev-match display + self._gear_before_clutch = 0 + self._last_clutch_state = False def set_visible(self, visible: bool): self._visible = visible @@ -46,12 +73,14 @@ def _render(self, rect: rl.Rectangle): self._update_counter = 0 self._load_stats() - # Get live data from CarState (always available, doesn't need param) + # Get live data from CarState cs = ui_state.sm['carState'] if ui_state.sm.valid['carState'] else None + if not cs: + return - # Widget dimensions - w = 140 - h = 130 + # Widget dimensions - wider for RPM bar + w = 180 + h = 160 x = int(rect.x + rect.width - w - 10) y = int(rect.y + 10) @@ -63,39 +92,78 @@ def _render(self, rect: rl.Rectangle): px = x + 10 py = y + 8 - # Current gear from CarState (big) - always show this - gear = cs.gearActual if cs else 0 + # === RPM METER === + rpm = cs.engineRpm + self._draw_rpm_meter(px, py, w - 20, 35, rpm, cs) + py += 42 + + # === GEAR + SHIFT GRADE FLASH === + gear = cs.gearActual gear_text = str(gear) if gear > 0 else "N" - rl.draw_text_ex(font_bold, gear_text, rl.Vector2(px, py), 42, 0, WHITE) - # Shift suggestion next to gear (from param stats) + # Check for new shift - only trigger when shiftGrade goes from 0 to non-zero + if cs.shiftGrade > 0 and self._last_shift_grade == 0: + # New shift detected - start flash with this grade + self._shift_flash_frames = 150 # Flash for 2.5s at 60fps + self._flash_grade = cs.shiftGrade # Store the grade to display + # Track the raw shiftGrade value + self._last_shift_grade = cs.shiftGrade + + # Draw gear with flash color if recently shifted + if self._shift_flash_frames > 0: + self._shift_flash_frames -= 1 + if self._flash_grade == 1: + gear_color = GREEN + grade_text = "✓" + elif self._flash_grade == 2: + gear_color = YELLOW + grade_text = "~" + else: + gear_color = RED + grade_text = "✗" + rl.draw_text_ex(font_bold, gear_text, rl.Vector2(px, py), 38, 0, gear_color) + rl.draw_text_ex(font_bold, grade_text, rl.Vector2(px + 30, py + 5), 28, 0, gear_color) + else: + rl.draw_text_ex(font_bold, gear_text, rl.Vector2(px, py), 38, 0, WHITE) + + # Shift suggestion arrow suggestion = self._stats.get('shift_suggestion', 'ok') if suggestion == 'upshift': - rl.draw_text_ex(font_bold, "↑", rl.Vector2(px + 35, py + 5), 36, 0, GREEN) + rl.draw_text_ex(font_bold, "↑", rl.Vector2(px + 65, py + 5), 30, 0, GREEN) elif suggestion == 'downshift': - rl.draw_text_ex(font_bold, "↓", rl.Vector2(px + 35, py + 5), 36, 0, YELLOW) - - py += 48 + rl.draw_text_ex(font_bold, "↓", rl.Vector2(px + 65, py + 5), 30, 0, YELLOW) + + py += 42 + + # === LAUNCH FEEDBACK === + launches = self._stats.get('launches', 0) + good_launches = self._stats.get('good_launches', 0) + # Detect if currently launching (low speed, was stopped) + if cs.vEgo < 5.0 and cs.vEgo > 0.5 and not cs.clutchPressed: + rl.draw_text_ex(font, "LAUNCHING...", rl.Vector2(px, py), 18, 0, CYAN) + elif launches > 0: + pct = int(good_launches / launches * 100) if launches > 0 else 0 + color = GREEN if pct >= 75 else (YELLOW if pct >= 50 else GRAY) + rl.draw_text_ex(font, f"Launch: {good_launches}/{launches}", rl.Vector2(px, py), 18, 0, color) + else: + rl.draw_text_ex(font, "Launch: -", rl.Vector2(px, py), 18, 0, GRAY) + py += 22 - # Stats in smaller text - font_size = 20 - line_h = 24 + # === STATS ROW === + font_size = 17 - # Stalls + # Stalls & Lugs on same line stalls = self._stats.get('stalls', 0) - color = GREEN if stalls == 0 else (YELLOW if stalls <= 2 else RED) - rl.draw_text_ex(font, f"Stalls: {stalls}", rl.Vector2(px, py), font_size, 0, color) - py += line_h - - # Lugging indicator - use CarState.isLugging for real-time, param for count - is_lugging = cs.isLugging if cs else False lugs = self._stats.get('lugs', 0) + is_lugging = cs.isLugging + if is_lugging: rl.draw_text_ex(font, "LUGGING!", rl.Vector2(px, py), font_size, 0, RED) else: - color = GREEN if lugs == 0 else GRAY - rl.draw_text_ex(font, f"Lugs: {lugs}", rl.Vector2(px, py), font_size, 0, color) - py += line_h + stall_color = GREEN if stalls == 0 else RED + lug_color = GREEN if lugs == 0 else YELLOW + rl.draw_text_ex(font, f"S:{stalls}", rl.Vector2(px, py), font_size, 0, stall_color) + rl.draw_text_ex(font, f"L:{lugs}", rl.Vector2(px + 45, py), font_size, 0, lug_color) # Shift quality shifts = self._stats.get('shifts', 0) @@ -103,13 +171,62 @@ def _render(self, rect: rl.Rectangle): if shifts > 0: pct = int(good_shifts / shifts * 100) color = GREEN if pct >= 80 else (YELLOW if pct >= 50 else RED) - rl.draw_text_ex(font, f"Shifts: {pct}%", rl.Vector2(px, py), font_size, 0, color) + rl.draw_text_ex(font, f"Sh:{pct}%", rl.Vector2(px + 95, py), font_size, 0, color) + else: + rl.draw_text_ex(font, "Sh:-", rl.Vector2(px + 95, py), font_size, 0, GRAY) + + def _draw_rpm_meter(self, x: int, y: int, w: int, h: int, rpm: float, cs): + """Draw RPM bar with color zones and rev-match target""" + font = gui_app.font(FontWeight.MEDIUM) + + # Bar background + bar_h = 14 + bar_y = y + 18 + rl.draw_rectangle_rounded(rl.Rectangle(x, bar_y, w, bar_h), 0.3, 5, rl.Color(40, 40, 40, 200)) + + # Calculate fill width + rpm_pct = min(rpm / RPM_REDLINE, 1.0) + fill_w = int(w * rpm_pct) + + # Color based on RPM zone + if rpm < RPM_ECONOMY_MAX: + bar_color = GREEN + elif rpm < RPM_POWER_MIN: + bar_color = YELLOW + elif rpm < RPM_DANGER_MIN: + bar_color = ORANGE else: - rl.draw_text_ex(font, "Shifts: -", rl.Vector2(px, py), font_size, 0, GRAY) + bar_color = RED + + # Draw filled portion + if fill_w > 0: + rl.draw_rectangle_rounded(rl.Rectangle(x, bar_y, fill_w, bar_h), 0.3, 5, bar_color) + + # Track gear before clutch press for rev-match display + if not cs.clutchPressed and cs.gearActual > 0: + self._gear_before_clutch = cs.gearActual + + # Rev-match target line when clutch pressed (show target for downshift) + if cs.clutchPressed and self._gear_before_clutch > 1: + # Calculate target RPM for downshift to next lower gear + target_gear = self._gear_before_clutch - 1 + target_rpm = rpm_for_speed_and_gear(cs.vEgo, target_gear) + if 0 < target_rpm < RPM_REDLINE: + target_x = x + int(w * (target_rpm / RPM_REDLINE)) + # Draw target line + rl.draw_rectangle(target_x - 1, bar_y - 3, 3, bar_h + 6, CYAN) + # Draw small target RPM text + rl.draw_text_ex(font, f"{int(target_rpm)}", rl.Vector2(target_x - 15, bar_y - 14), 12, 0, CYAN) + + # RPM text + rpm_text = f"{int(rpm)}" + rl.draw_text_ex(font, rpm_text, rl.Vector2(x, y), 16, 0, WHITE) + rl.draw_text_ex(font, "rpm", rl.Vector2(x + 45, y + 2), 12, 0, GRAY) def _load_stats(self): """Load current session stats""" try: - self._stats = self._params.get("ManualDriveLiveStats") + data = self._params.get("ManualDriveLiveStats") + self._stats = data if data else {} except Exception: self._stats = {} From a3785e8136c2af2e3a19ed5d7e1587a4b6d59c7c Mon Sep 17 00:00:00 2001 From: Shane Smiskol Date: Sun, 25 Jan 2026 00:52:20 -0800 Subject: [PATCH 11/15] tweaks --- opendbc_repo | 2 +- .../ui/mici/onroad/manual_stats_widget.py | 122 +++++++++++------- 2 files changed, 73 insertions(+), 51 deletions(-) diff --git a/opendbc_repo b/opendbc_repo index 4ab73ae3d4de31..ce6dc0eab68e8c 160000 --- a/opendbc_repo +++ b/opendbc_repo @@ -1 +1 @@ -Subproject commit 4ab73ae3d4de3195cf4b9848ceb54d51e9541d0d +Subproject commit ce6dc0eab68e8ce9e3f5bae9e5623c98f5193f8a diff --git a/selfdrive/ui/mici/onroad/manual_stats_widget.py b/selfdrive/ui/mici/onroad/manual_stats_widget.py index 4969be5d1d7162..914816cb0de6ba 100644 --- a/selfdrive/ui/mici/onroad/manual_stats_widget.py +++ b/selfdrive/ui/mici/onroad/manual_stats_widget.py @@ -9,6 +9,7 @@ import pyray as rl from openpilot.common.params import Params +from opendbc.car.common.filter_simple import FirstOrderFilter from openpilot.selfdrive.ui.ui_state import ui_state from openpilot.system.ui.lib.application import gui_app, FontWeight from openpilot.system.ui.widgets import Widget @@ -29,6 +30,7 @@ RPM_ECONOMY_MAX = 2500 RPM_POWER_MIN = 4000 RPM_DANGER_MIN = 6500 +RPM_TARGET_MIN_DISPLAY = 750 # Don't show upshift indicator below this RPM # 2024 BRZ gear ratios for rev-match calculation BRZ_GEAR_RATIOS = {1: 3.626, 2: 2.188, 3: 1.541, 4: 1.213, 5: 1.000, 6: 0.767} @@ -59,14 +61,10 @@ def __init__(self): # Track gear before clutch for rev-match display self._gear_before_clutch = 0 self._last_clutch_state = False - - def set_visible(self, visible: bool): - self._visible = visible + # Filtered RPM for smooth label display (0.1s time constant, ~60fps) + self._rpm_filter = FirstOrderFilter(0, 0.1, 1/60) def _render(self, rect: rl.Rectangle): - if not self._visible: - return - # Update stats every ~15 frames (0.25s at 60fps) self._update_counter += 1 if self._update_counter >= 15: @@ -74,28 +72,29 @@ def _render(self, rect: rl.Rectangle): self._load_stats() # Get live data from CarState - cs = ui_state.sm['carState'] if ui_state.sm.valid['carState'] else None + cs = ui_state.sm['carState']# if ui_state.sm.valid['carState'] else None if not cs: return - # Widget dimensions - wider for RPM bar - w = 180 - h = 160 - x = int(rect.x + rect.width - w - 10) - y = int(rect.y + 10) + # Widget dimensions - extend to bottom with same margin as top + margin = 10 + w = 250 + h = int(rect.height - 2 * margin) # Full height minus top and bottom margin + x = int(rect.x + rect.width - w - margin) + y = int(rect.y + margin) # Background - rl.draw_rectangle_rounded(rl.Rectangle(x, y, w, h), 0.1, 10, BG_COLOR) + rl.draw_rectangle_rounded(rl.Rectangle(x, y, w, h), 0.08, 10, BG_COLOR) font = gui_app.font(FontWeight.MEDIUM) font_bold = gui_app.font(FontWeight.BOLD) - px = x + 10 - py = y + 8 + px = x + 14 + py = y + 12 # === RPM METER === rpm = cs.engineRpm - self._draw_rpm_meter(px, py, w - 20, 35, rpm, cs) - py += 42 + self._draw_rpm_meter(px, py, w - 28, 50, rpm, cs) + py += 62 # === GEAR + SHIFT GRADE FLASH === gear = cs.gearActual @@ -121,36 +120,36 @@ def _render(self, rect: rl.Rectangle): else: gear_color = RED grade_text = "✗" - rl.draw_text_ex(font_bold, gear_text, rl.Vector2(px, py), 38, 0, gear_color) - rl.draw_text_ex(font_bold, grade_text, rl.Vector2(px + 30, py + 5), 28, 0, gear_color) + rl.draw_text_ex(font_bold, gear_text, rl.Vector2(px, py), 55, 0, gear_color) + rl.draw_text_ex(font_bold, grade_text, rl.Vector2(px + 42, py + 8), 40, 0, gear_color) else: - rl.draw_text_ex(font_bold, gear_text, rl.Vector2(px, py), 38, 0, WHITE) + rl.draw_text_ex(font_bold, gear_text, rl.Vector2(px, py), 55, 0, WHITE) # Shift suggestion arrow suggestion = self._stats.get('shift_suggestion', 'ok') if suggestion == 'upshift': - rl.draw_text_ex(font_bold, "↑", rl.Vector2(px + 65, py + 5), 30, 0, GREEN) + rl.draw_text_ex(font_bold, "↑", rl.Vector2(px + 95, py + 8), 43, 0, GREEN) elif suggestion == 'downshift': - rl.draw_text_ex(font_bold, "↓", rl.Vector2(px + 65, py + 5), 30, 0, YELLOW) + rl.draw_text_ex(font_bold, "↓", rl.Vector2(px + 95, py + 8), 43, 0, YELLOW) - py += 42 + py += 62 # === LAUNCH FEEDBACK === launches = self._stats.get('launches', 0) good_launches = self._stats.get('good_launches', 0) # Detect if currently launching (low speed, was stopped) if cs.vEgo < 5.0 and cs.vEgo > 0.5 and not cs.clutchPressed: - rl.draw_text_ex(font, "LAUNCHING...", rl.Vector2(px, py), 18, 0, CYAN) + rl.draw_text_ex(font, "LAUNCHING...", rl.Vector2(px, py), 26, 0, CYAN) elif launches > 0: pct = int(good_launches / launches * 100) if launches > 0 else 0 color = GREEN if pct >= 75 else (YELLOW if pct >= 50 else GRAY) - rl.draw_text_ex(font, f"Launch: {good_launches}/{launches}", rl.Vector2(px, py), 18, 0, color) + rl.draw_text_ex(font, f"Launch: {good_launches}/{launches}", rl.Vector2(px, py), 26, 0, color) else: - rl.draw_text_ex(font, "Launch: -", rl.Vector2(px, py), 18, 0, GRAY) - py += 22 + rl.draw_text_ex(font, "Launch: -", rl.Vector2(px, py), 26, 0, GRAY) + py += 34 # === STATS ROW === - font_size = 17 + font_size = 24 # Stalls & Lugs on same line stalls = self._stats.get('stalls', 0) @@ -163,7 +162,7 @@ def _render(self, rect: rl.Rectangle): stall_color = GREEN if stalls == 0 else RED lug_color = GREEN if lugs == 0 else YELLOW rl.draw_text_ex(font, f"S:{stalls}", rl.Vector2(px, py), font_size, 0, stall_color) - rl.draw_text_ex(font, f"L:{lugs}", rl.Vector2(px + 45, py), font_size, 0, lug_color) + rl.draw_text_ex(font, f"L:{lugs}", rl.Vector2(px + 65, py), font_size, 0, lug_color) # Shift quality shifts = self._stats.get('shifts', 0) @@ -171,17 +170,17 @@ def _render(self, rect: rl.Rectangle): if shifts > 0: pct = int(good_shifts / shifts * 100) color = GREEN if pct >= 80 else (YELLOW if pct >= 50 else RED) - rl.draw_text_ex(font, f"Sh:{pct}%", rl.Vector2(px + 95, py), font_size, 0, color) + rl.draw_text_ex(font, f"Sh:{pct}%", rl.Vector2(px + 135, py), font_size, 0, color) else: - rl.draw_text_ex(font, "Sh:-", rl.Vector2(px + 95, py), font_size, 0, GRAY) + rl.draw_text_ex(font, "Sh:-", rl.Vector2(px + 135, py), font_size, 0, GRAY) def _draw_rpm_meter(self, x: int, y: int, w: int, h: int, rpm: float, cs): """Draw RPM bar with color zones and rev-match target""" font = gui_app.font(FontWeight.MEDIUM) - # Bar background - bar_h = 14 - bar_y = y + 18 + # Bar background (pushed down for bigger RPM text) + bar_h = 20 + bar_y = y + 32 rl.draw_rectangle_rounded(rl.Rectangle(x, bar_y, w, bar_h), 0.3, 5, rl.Color(40, 40, 40, 200)) # Calculate fill width @@ -206,22 +205,45 @@ def _draw_rpm_meter(self, x: int, y: int, w: int, h: int, rpm: float, cs): if not cs.clutchPressed and cs.gearActual > 0: self._gear_before_clutch = cs.gearActual - # Rev-match target line when clutch pressed (show target for downshift) - if cs.clutchPressed and self._gear_before_clutch > 1: - # Calculate target RPM for downshift to next lower gear - target_gear = self._gear_before_clutch - 1 - target_rpm = rpm_for_speed_and_gear(cs.vEgo, target_gear) - if 0 < target_rpm < RPM_REDLINE: - target_x = x + int(w * (target_rpm / RPM_REDLINE)) - # Draw target line - rl.draw_rectangle(target_x - 1, bar_y - 3, 3, bar_h + 6, CYAN) - # Draw small target RPM text - rl.draw_text_ex(font, f"{int(target_rpm)}", rl.Vector2(target_x - 15, bar_y - 14), 12, 0, CYAN) - - # RPM text - rpm_text = f"{int(rpm)}" - rl.draw_text_ex(font, rpm_text, rl.Vector2(x, y), 16, 0, WHITE) - rl.draw_text_ex(font, "rpm", rl.Vector2(x + 45, y + 2), 12, 0, GRAY) + # Rev-match target lines when clutch pressed + if cs.clutchPressed and self._gear_before_clutch > 0: + # Calculate both targets first + down_rpm = 0 + up_rpm = 0 + if self._gear_before_clutch > 1: + down_rpm = rpm_for_speed_and_gear(cs.vEgo, self._gear_before_clutch - 1) + if self._gear_before_clutch < 6: + up_rpm = rpm_for_speed_and_gear(cs.vEgo, self._gear_before_clutch + 1) + + # Downshift target - cyan if safe, red if over redline + if down_rpm >= RPM_REDLINE: + # Over redline - show red warning clipped to right side + down_x = x + w + rl.draw_rectangle(down_x - 4, bar_y - 5, 4, bar_h + 10, RED) + down_text = f"{int(down_rpm)}!" + down_tw = rl.measure_text_ex(font, down_text, 20, 0).x + rl.draw_text_ex(font, down_text, rl.Vector2(down_x - down_tw / 2, bar_y + bar_h + 3), 20, 0, RED) + elif down_rpm > RPM_TARGET_MIN_DISPLAY: + # Safe downshift target (cyan) + down_x = x + int(w * (down_rpm / RPM_REDLINE)) + rl.draw_rectangle(down_x - 2, bar_y - 5, 4, bar_h + 10, CYAN) + down_text = f"{int(down_rpm)}" + down_tw = rl.measure_text_ex(font, down_text, 20, 0).x + rl.draw_text_ex(font, down_text, rl.Vector2(down_x - down_tw / 2, bar_y + bar_h + 3), 20, 0, CYAN) + + # Upshift target (white) - only show if above minimum display threshold + if up_rpm > RPM_TARGET_MIN_DISPLAY and up_rpm < RPM_REDLINE: + up_x = x + int(w * (up_rpm / RPM_REDLINE)) + rl.draw_rectangle(up_x - 2, bar_y - 5, 4, bar_h + 10, WHITE) + up_text = f"{int(up_rpm)}" + up_tw = rl.measure_text_ex(font, up_text, 20, 0).x + rl.draw_text_ex(font, up_text, rl.Vector2(up_x - up_tw / 2, bar_y + bar_h + 3), 20, 0, WHITE) + + # RPM text (filtered for smooth display, rounded to nearest 10) + self._rpm_filter.update(rpm) + rpm_text = f"{int(round(self._rpm_filter.x / 10) * 10)}" + rl.draw_text_ex(font, rpm_text, rl.Vector2(x, y), 28, 0, WHITE) + rl.draw_text_ex(font, "rpm", rl.Vector2(x + 70, y + 5), 20, 0, GRAY) def _load_stats(self): """Load current session stats""" From d86b4353e8430a8863e03f40a302a6df5c88d008 Mon Sep 17 00:00:00 2001 From: Shane Smiskol Date: Sun, 25 Jan 2026 01:01:35 -0800 Subject: [PATCH 12/15] show rev matchers when shift suggesstion --- .../ui/mici/onroad/manual_stats_widget.py | 18 +++++++----------- 1 file changed, 7 insertions(+), 11 deletions(-) diff --git a/selfdrive/ui/mici/onroad/manual_stats_widget.py b/selfdrive/ui/mici/onroad/manual_stats_widget.py index 914816cb0de6ba..01803a3d351e38 100644 --- a/selfdrive/ui/mici/onroad/manual_stats_widget.py +++ b/selfdrive/ui/mici/onroad/manual_stats_widget.py @@ -205,8 +205,10 @@ def _draw_rpm_meter(self, x: int, y: int, w: int, h: int, rpm: float, cs): if not cs.clutchPressed and cs.gearActual > 0: self._gear_before_clutch = cs.gearActual - # Rev-match target lines when clutch pressed - if cs.clutchPressed and self._gear_before_clutch > 0: + # Rev-match target lines when clutch pressed OR shift suggestion showing + suggestion = self._stats.get('shift_suggestion', 'ok') + show_rev_targets = (cs.clutchPressed or suggestion != 'ok') and self._gear_before_clutch > 0 + if show_rev_targets: # Calculate both targets first down_rpm = 0 up_rpm = 0 @@ -220,24 +222,18 @@ def _draw_rpm_meter(self, x: int, y: int, w: int, h: int, rpm: float, cs): # Over redline - show red warning clipped to right side down_x = x + w rl.draw_rectangle(down_x - 4, bar_y - 5, 4, bar_h + 10, RED) - down_text = f"{int(down_rpm)}!" - down_tw = rl.measure_text_ex(font, down_text, 20, 0).x - rl.draw_text_ex(font, down_text, rl.Vector2(down_x - down_tw / 2, bar_y + bar_h + 3), 20, 0, RED) + rl.draw_text_ex(font, f"{int(round(down_rpm / 10) * 10)}!", rl.Vector2(down_x - 45, bar_y + bar_h + 3), 20, 0, RED) elif down_rpm > RPM_TARGET_MIN_DISPLAY: # Safe downshift target (cyan) down_x = x + int(w * (down_rpm / RPM_REDLINE)) rl.draw_rectangle(down_x - 2, bar_y - 5, 4, bar_h + 10, CYAN) - down_text = f"{int(down_rpm)}" - down_tw = rl.measure_text_ex(font, down_text, 20, 0).x - rl.draw_text_ex(font, down_text, rl.Vector2(down_x - down_tw / 2, bar_y + bar_h + 3), 20, 0, CYAN) + rl.draw_text_ex(font, f"{int(round(down_rpm / 10) * 10)}", rl.Vector2(down_x - 20, bar_y + bar_h + 3), 20, 0, CYAN) # Upshift target (white) - only show if above minimum display threshold if up_rpm > RPM_TARGET_MIN_DISPLAY and up_rpm < RPM_REDLINE: up_x = x + int(w * (up_rpm / RPM_REDLINE)) rl.draw_rectangle(up_x - 2, bar_y - 5, 4, bar_h + 10, WHITE) - up_text = f"{int(up_rpm)}" - up_tw = rl.measure_text_ex(font, up_text, 20, 0).x - rl.draw_text_ex(font, up_text, rl.Vector2(up_x - up_tw / 2, bar_y + bar_h + 3), 20, 0, WHITE) + rl.draw_text_ex(font, f"{int(round(up_rpm / 10) * 10)}", rl.Vector2(up_x - 20, bar_y + bar_h + 3), 20, 0, WHITE) # RPM text (filtered for smooth display, rounded to nearest 10) self._rpm_filter.update(rpm) From 056fd36c157daf273c707dda02e2de205d2a72e7 Mon Sep 17 00:00:00 2001 From: Shane Smiskol Date: Sun, 25 Jan 2026 01:05:59 -0800 Subject: [PATCH 13/15] darker when suggested --- .../ui/mici/onroad/manual_stats_widget.py | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/selfdrive/ui/mici/onroad/manual_stats_widget.py b/selfdrive/ui/mici/onroad/manual_stats_widget.py index 01803a3d351e38..42e6a7d0bb3fb7 100644 --- a/selfdrive/ui/mici/onroad/manual_stats_widget.py +++ b/selfdrive/ui/mici/onroad/manual_stats_widget.py @@ -209,6 +209,12 @@ def _draw_rpm_meter(self, x: int, y: int, w: int, h: int, rpm: float, cs): suggestion = self._stats.get('shift_suggestion', 'ok') show_rev_targets = (cs.clutchPressed or suggestion != 'ok') and self._gear_before_clutch > 0 if show_rev_targets: + # 65% opacity when showing due to suggestion only (not clutch) + alpha = 220 if cs.clutchPressed else 143 + cyan = rl.Color(CYAN.r, CYAN.g, CYAN.b, alpha) + red = rl.Color(RED.r, RED.g, RED.b, alpha) + white = rl.Color(WHITE.r, WHITE.g, WHITE.b, alpha) + # Calculate both targets first down_rpm = 0 up_rpm = 0 @@ -221,19 +227,19 @@ def _draw_rpm_meter(self, x: int, y: int, w: int, h: int, rpm: float, cs): if down_rpm >= RPM_REDLINE: # Over redline - show red warning clipped to right side down_x = x + w - rl.draw_rectangle(down_x - 4, bar_y - 5, 4, bar_h + 10, RED) - rl.draw_text_ex(font, f"{int(round(down_rpm / 10) * 10)}!", rl.Vector2(down_x - 45, bar_y + bar_h + 3), 20, 0, RED) + rl.draw_rectangle(down_x - 4, bar_y - 5, 4, bar_h + 10, red) + rl.draw_text_ex(font, f"{int(round(down_rpm / 10) * 10)}!", rl.Vector2(down_x - 45, bar_y + bar_h + 3), 20, 0, red) elif down_rpm > RPM_TARGET_MIN_DISPLAY: # Safe downshift target (cyan) down_x = x + int(w * (down_rpm / RPM_REDLINE)) - rl.draw_rectangle(down_x - 2, bar_y - 5, 4, bar_h + 10, CYAN) - rl.draw_text_ex(font, f"{int(round(down_rpm / 10) * 10)}", rl.Vector2(down_x - 20, bar_y + bar_h + 3), 20, 0, CYAN) + rl.draw_rectangle(down_x - 2, bar_y - 5, 4, bar_h + 10, cyan) + rl.draw_text_ex(font, f"{int(round(down_rpm / 10) * 10)}", rl.Vector2(down_x - 20, bar_y + bar_h + 3), 20, 0, cyan) # Upshift target (white) - only show if above minimum display threshold if up_rpm > RPM_TARGET_MIN_DISPLAY and up_rpm < RPM_REDLINE: up_x = x + int(w * (up_rpm / RPM_REDLINE)) - rl.draw_rectangle(up_x - 2, bar_y - 5, 4, bar_h + 10, WHITE) - rl.draw_text_ex(font, f"{int(round(up_rpm / 10) * 10)}", rl.Vector2(up_x - 20, bar_y + bar_h + 3), 20, 0, WHITE) + rl.draw_rectangle(up_x - 2, bar_y - 5, 4, bar_h + 10, white) + rl.draw_text_ex(font, f"{int(round(up_rpm / 10) * 10)}", rl.Vector2(up_x - 20, bar_y + bar_h + 3), 20, 0, white) # RPM text (filtered for smooth display, rounded to nearest 10) self._rpm_filter.update(rpm) From 33456fd11b9c27125d42e1b2804a7e5d85eec09e Mon Sep 17 00:00:00 2001 From: Shane Smiskol Date: Sun, 25 Jan 2026 01:14:49 -0800 Subject: [PATCH 14/15] show more gears and gear label --- .../ui/mici/onroad/manual_stats_widget.py | 63 +++++++++++-------- 1 file changed, 37 insertions(+), 26 deletions(-) diff --git a/selfdrive/ui/mici/onroad/manual_stats_widget.py b/selfdrive/ui/mici/onroad/manual_stats_widget.py index 42e6a7d0bb3fb7..c2ae69a8b52954 100644 --- a/selfdrive/ui/mici/onroad/manual_stats_widget.py +++ b/selfdrive/ui/mici/onroad/manual_stats_widget.py @@ -214,32 +214,43 @@ def _draw_rpm_meter(self, x: int, y: int, w: int, h: int, rpm: float, cs): cyan = rl.Color(CYAN.r, CYAN.g, CYAN.b, alpha) red = rl.Color(RED.r, RED.g, RED.b, alpha) white = rl.Color(WHITE.r, WHITE.g, WHITE.b, alpha) - - # Calculate both targets first - down_rpm = 0 - up_rpm = 0 - if self._gear_before_clutch > 1: - down_rpm = rpm_for_speed_and_gear(cs.vEgo, self._gear_before_clutch - 1) - if self._gear_before_clutch < 6: - up_rpm = rpm_for_speed_and_gear(cs.vEgo, self._gear_before_clutch + 1) - - # Downshift target - cyan if safe, red if over redline - if down_rpm >= RPM_REDLINE: - # Over redline - show red warning clipped to right side - down_x = x + w - rl.draw_rectangle(down_x - 4, bar_y - 5, 4, bar_h + 10, red) - rl.draw_text_ex(font, f"{int(round(down_rpm / 10) * 10)}!", rl.Vector2(down_x - 45, bar_y + bar_h + 3), 20, 0, red) - elif down_rpm > RPM_TARGET_MIN_DISPLAY: - # Safe downshift target (cyan) - down_x = x + int(w * (down_rpm / RPM_REDLINE)) - rl.draw_rectangle(down_x - 2, bar_y - 5, 4, bar_h + 10, cyan) - rl.draw_text_ex(font, f"{int(round(down_rpm / 10) * 10)}", rl.Vector2(down_x - 20, bar_y + bar_h + 3), 20, 0, cyan) - - # Upshift target (white) - only show if above minimum display threshold - if up_rpm > RPM_TARGET_MIN_DISPLAY and up_rpm < RPM_REDLINE: - up_x = x + int(w * (up_rpm / RPM_REDLINE)) - rl.draw_rectangle(up_x - 2, bar_y - 5, 4, bar_h + 10, white) - rl.draw_text_ex(font, f"{int(round(up_rpm / 10) * 10)}", rl.Vector2(up_x - 20, bar_y + bar_h + 3), 20, 0, white) + gray = rl.Color(GRAY.r, GRAY.g, GRAY.b, alpha) + + # Find lowest gear at redline (don't show gears below it) + lowest_redline_gear = 7 # None at redline + for gear in range(1, 7): + if rpm_for_speed_and_gear(cs.vEgo, gear) >= RPM_REDLINE: + lowest_redline_gear = gear + break + + # Show gears with gear numbers (2 adjacent on each side) + LUG_RPM = 1500 # Hide gears that would lug or be under idle + min_gear = max(1, self._gear_before_clutch - 2) + max_gear = min(6, self._gear_before_clutch + 2) + for gear in range(min_gear, max_gear + 1): + gear_rpm = rpm_for_speed_and_gear(cs.vEgo, gear) + if gear_rpm < LUG_RPM: + continue # Would lug or be under idle + if gear < lowest_redline_gear and lowest_redline_gear <= 6: + continue # Skip gears below the lowest redline gear + + # Choose color based on gear relative to current + if gear_rpm >= RPM_REDLINE: + # Over redline - red, clipped to right + gear_x = x + w + color = red + rl.draw_rectangle(gear_x - 4, bar_y - 5, 4, bar_h + 10, color) + rl.draw_text_ex(font, f"{gear}!", rl.Vector2(gear_x - 18, bar_y + bar_h + 3), 20, 0, color) + else: + gear_x = x + int(w * (gear_rpm / RPM_REDLINE)) + if gear == self._gear_before_clutch - 1: + color = cyan # Downshift target + elif gear == self._gear_before_clutch + 1: + color = white # Upshift target + else: + color = gray # Other gears + rl.draw_rectangle(gear_x - 2, bar_y - 5, 4, bar_h + 10, color) + rl.draw_text_ex(font, str(gear), rl.Vector2(gear_x - 5, bar_y + bar_h + 3), 20, 0, color) # RPM text (filtered for smooth display, rounded to nearest 10) self._rpm_filter.update(rpm) From 477e105221d7cbb2e8ad1f367cb7b57b74c7402b Mon Sep 17 00:00:00 2001 From: Shane Smiskol Date: Sun, 25 Jan 2026 01:14:57 -0800 Subject: [PATCH 15/15] Revert "show more gears and gear label" This reverts commit 33456fd11b9c27125d42e1b2804a7e5d85eec09e. --- .../ui/mici/onroad/manual_stats_widget.py | 63 ++++++++----------- 1 file changed, 26 insertions(+), 37 deletions(-) diff --git a/selfdrive/ui/mici/onroad/manual_stats_widget.py b/selfdrive/ui/mici/onroad/manual_stats_widget.py index c2ae69a8b52954..42e6a7d0bb3fb7 100644 --- a/selfdrive/ui/mici/onroad/manual_stats_widget.py +++ b/selfdrive/ui/mici/onroad/manual_stats_widget.py @@ -214,43 +214,32 @@ def _draw_rpm_meter(self, x: int, y: int, w: int, h: int, rpm: float, cs): cyan = rl.Color(CYAN.r, CYAN.g, CYAN.b, alpha) red = rl.Color(RED.r, RED.g, RED.b, alpha) white = rl.Color(WHITE.r, WHITE.g, WHITE.b, alpha) - gray = rl.Color(GRAY.r, GRAY.g, GRAY.b, alpha) - - # Find lowest gear at redline (don't show gears below it) - lowest_redline_gear = 7 # None at redline - for gear in range(1, 7): - if rpm_for_speed_and_gear(cs.vEgo, gear) >= RPM_REDLINE: - lowest_redline_gear = gear - break - - # Show gears with gear numbers (2 adjacent on each side) - LUG_RPM = 1500 # Hide gears that would lug or be under idle - min_gear = max(1, self._gear_before_clutch - 2) - max_gear = min(6, self._gear_before_clutch + 2) - for gear in range(min_gear, max_gear + 1): - gear_rpm = rpm_for_speed_and_gear(cs.vEgo, gear) - if gear_rpm < LUG_RPM: - continue # Would lug or be under idle - if gear < lowest_redline_gear and lowest_redline_gear <= 6: - continue # Skip gears below the lowest redline gear - - # Choose color based on gear relative to current - if gear_rpm >= RPM_REDLINE: - # Over redline - red, clipped to right - gear_x = x + w - color = red - rl.draw_rectangle(gear_x - 4, bar_y - 5, 4, bar_h + 10, color) - rl.draw_text_ex(font, f"{gear}!", rl.Vector2(gear_x - 18, bar_y + bar_h + 3), 20, 0, color) - else: - gear_x = x + int(w * (gear_rpm / RPM_REDLINE)) - if gear == self._gear_before_clutch - 1: - color = cyan # Downshift target - elif gear == self._gear_before_clutch + 1: - color = white # Upshift target - else: - color = gray # Other gears - rl.draw_rectangle(gear_x - 2, bar_y - 5, 4, bar_h + 10, color) - rl.draw_text_ex(font, str(gear), rl.Vector2(gear_x - 5, bar_y + bar_h + 3), 20, 0, color) + + # Calculate both targets first + down_rpm = 0 + up_rpm = 0 + if self._gear_before_clutch > 1: + down_rpm = rpm_for_speed_and_gear(cs.vEgo, self._gear_before_clutch - 1) + if self._gear_before_clutch < 6: + up_rpm = rpm_for_speed_and_gear(cs.vEgo, self._gear_before_clutch + 1) + + # Downshift target - cyan if safe, red if over redline + if down_rpm >= RPM_REDLINE: + # Over redline - show red warning clipped to right side + down_x = x + w + rl.draw_rectangle(down_x - 4, bar_y - 5, 4, bar_h + 10, red) + rl.draw_text_ex(font, f"{int(round(down_rpm / 10) * 10)}!", rl.Vector2(down_x - 45, bar_y + bar_h + 3), 20, 0, red) + elif down_rpm > RPM_TARGET_MIN_DISPLAY: + # Safe downshift target (cyan) + down_x = x + int(w * (down_rpm / RPM_REDLINE)) + rl.draw_rectangle(down_x - 2, bar_y - 5, 4, bar_h + 10, cyan) + rl.draw_text_ex(font, f"{int(round(down_rpm / 10) * 10)}", rl.Vector2(down_x - 20, bar_y + bar_h + 3), 20, 0, cyan) + + # Upshift target (white) - only show if above minimum display threshold + if up_rpm > RPM_TARGET_MIN_DISPLAY and up_rpm < RPM_REDLINE: + up_x = x + int(w * (up_rpm / RPM_REDLINE)) + rl.draw_rectangle(up_x - 2, bar_y - 5, 4, bar_h + 10, white) + rl.draw_text_ex(font, f"{int(round(up_rpm / 10) * 10)}", rl.Vector2(up_x - 20, bar_y + bar_h + 3), 20, 0, white) # RPM text (filtered for smooth display, rounded to nearest 10) self._rpm_filter.update(rpm)