From 4af905a4634d8cea5c18808ada4667ffd53474cf Mon Sep 17 00:00:00 2001 From: Shane Smiskol Date: Fri, 23 Jan 2026 00:30:46 -0800 Subject: [PATCH 01/23] 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/23] 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/23] 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/23] "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/23] 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/23] 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/23] 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/23] 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/23] 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/23] 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/23] 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/23] 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/23] 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/23] 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/23] 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) From 056479707d82d23ad69cdc1c1910d1a9a1584272 Mon Sep 17 00:00:00 2001 From: Shane Smiskol Date: Sat, 7 Feb 2026 22:09:57 -0800 Subject: [PATCH 16/23] clean up from cursor --- common/params_keys.h | 1 - selfdrive/ui/mici/layouts/main.py | 42 +++---- .../ui/mici/layouts/manual_drive_summary.py | 105 +++++++++--------- .../ui/mici/layouts/settings/manual_stats.py | 44 +++++--- .../ui/mici/onroad/manual_stats_widget.py | 7 +- 5 files changed, 102 insertions(+), 97 deletions(-) diff --git a/common/params_keys.h b/common/params_keys.h index bb51fbb1a48386..b793837eada390 100644 --- a/common/params_keys.h +++ b/common/params_keys.h @@ -85,7 +85,6 @@ inline static std::unordered_map keys = { {"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}}, diff --git a/selfdrive/ui/mici/layouts/main.py b/selfdrive/ui/mici/layouts/main.py index d0768fc76dff44..ca27592f2b1316 100644 --- a/selfdrive/ui/mici/layouts/main.py +++ b/selfdrive/ui/mici/layouts/main.py @@ -134,30 +134,24 @@ def _handle_transitions(self): 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 + """Show end-of-drive summary dialog if there's data worth showing. + All stats are saved by the card process -- UI just reads and displays.""" + data = self._params.get("ManualDriveStats") + if not data: + return + stats = data if isinstance(data, dict) else json.loads(data) + history = stats.get('session_history', []) + if not history: + return + + session = history[-1] + duration = session.get('duration', 0) + has_activity = (session.get('stalls', 0) > 0 or + session.get('upshifts', 0) > 0 or + session.get('launches', 0) > 0) + if duration > 30 and has_activity: + self._drive_summary_dialog = ManualDriveSummaryDialog() + gui_app.set_modal_overlay(self._drive_summary_dialog) def _set_mode_for_started(self, onroad_transition: bool = False): if ui_state.started: diff --git a/selfdrive/ui/mici/layouts/manual_drive_summary.py b/selfdrive/ui/mici/layouts/manual_drive_summary.py index 37119e1a87fe80..d264bdec8e7d44 100644 --- a/selfdrive/ui/mici/layouts/manual_drive_summary.py +++ b/selfdrive/ui/mici/layouts/manual_drive_summary.py @@ -71,36 +71,37 @@ def show_event(self): 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 = data if isinstance(data, dict) else json.loads(data) + """Load the last session data from session_history in ManualDriveStats""" + data = self._params.get("ManualDriveStats") + if data: + stats = data if isinstance(data, dict) else json.loads(data) + history = stats.get('session_history', []) + if history: + self._session_data = history[-1] self._calculate_grade() - except Exception: - self._session_data = None + return + 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: + 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) + else: self._historical_data = None def _calculate_grade(self): @@ -112,19 +113,19 @@ def _calculate_grade(self): 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) + stalls = self._session_data.get('stalls', 0) + lugs = self._session_data.get('lugs', 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) + upshift_total = self._session_data.get('upshifts', 0) + upshift_good = self._session_data.get('upshifts_good', 0) + downshift_total = self._session_data.get('downshifts', 0) + downshift_good = self._session_data.get('downshifts_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) + launch_total = self._session_data.get('launches', 0) + launch_good = self._session_data.get('launches_good', 0) + launch_stalled = self._session_data.get('launches_stalled', 0) # Calculate scores total_shifts = upshift_total + downshift_total @@ -169,16 +170,16 @@ def _get_encouragement_text(self) -> str: 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) + stalls = self._session_data.get('stalls', 0) + lugs = self._session_data.get('lugs', 0) + launch_stalled = self._session_data.get('launches_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) + upshift_good = self._session_data.get('upshifts_good', 0) + upshift_total = self._session_data.get('upshifts', 0) + downshift_good = self._session_data.get('downshifts_good', 0) + downshift_total = self._session_data.get('downshifts', 0) + launch_good = self._session_data.get('launches_good', 0) + launch_total = self._session_data.get('launches', 0) messages = [] @@ -304,8 +305,8 @@ def _render(self, rect: rl.Rectangle): card_y = y + 12 # Jackets section (stalls + lugs) - 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 + stalls = self._session_data.get('stalls', 0) if self._session_data else 0 + lugs = self._session_data.get('lugs', 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) @@ -319,12 +320,12 @@ 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 - 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 + upshift_total = self._session_data.get('upshifts', 0) if self._session_data else 0 + upshift_good = self._session_data.get('upshifts_good', 0) if self._session_data else 0 + downshift_total = self._session_data.get('downshifts', 0) if self._session_data else 0 + downshift_good = self._session_data.get('downshifts_good', 0) if self._session_data else 0 + launch_total = self._session_data.get('launches', 0) if self._session_data else 0 + launch_good = self._session_data.get('launches_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) diff --git a/selfdrive/ui/mici/layouts/settings/manual_stats.py b/selfdrive/ui/mici/layouts/settings/manual_stats.py index afc7d1e3c9abfd..d92dd3ca8502a2 100644 --- a/selfdrive/ui/mici/layouts/settings/manual_stats.py +++ b/selfdrive/ui/mici/layouts/settings/manual_stats.py @@ -42,14 +42,10 @@ def show_event(self): 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: + data = self._params.get("ManualDriveStats") + if data: + self._stats = data if isinstance(data, dict) else json.loads(data) + else: self._stats = {} def _render(self, rect: rl.Rectangle): @@ -125,9 +121,15 @@ def _render(self, rect: rl.Rectangle): ]) y += 15 - # Trend card - recent_stalls = self._stats.get('recent_stall_rates', []) - recent_shifts = self._stats.get('recent_shift_scores', []) + # Trend card - derive from session_history + session_history = self._stats.get('session_history', []) + recent_sessions = session_history[-10:] + recent_stalls = [s.get('stalls', 0) for s in recent_sessions] + recent_shifts = [] + for s in recent_sessions: + total = s.get('upshifts', 0) + s.get('downshifts', 0) + good = s.get('upshifts_good', 0) + s.get('downshifts_good', 0) + recent_shifts.append(int(good / total * 100) if total > 0 else 100) trend_items = [] if len(recent_stalls) >= 2: @@ -580,8 +582,13 @@ def _get_overall_hand(self) -> tuple[str, rl.Color]: # Calculate overall score score = shift_pct - (stall_rate * 10) - # Recent improvement bonus - recent_scores = self._stats.get('recent_shift_scores', []) + # Recent improvement bonus - derive from session_history + session_history = self._stats.get('session_history', []) + recent_scores = [] + for s in session_history[-10:]: + total = s.get('upshifts', 0) + s.get('downshifts', 0) + good = s.get('upshifts_good', 0) + s.get('downshifts_good', 0) + recent_scores.append(int(good / total * 100) if total > 0 else 100) if len(recent_scores) >= 3: if recent_scores[-1] > recent_scores[0]: score += 5 # Bonus for improving @@ -613,8 +620,15 @@ 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', []) + # Derive recent trends from session_history + session_history = self._stats.get('session_history', []) + recent_sessions = session_history[-10:] + recent_stalls = [s.get('stalls', 0) for s in recent_sessions] + recent_scores = [] + for s in recent_sessions: + total = s.get('upshifts', 0) + s.get('downshifts', 0) + good = s.get('upshifts_good', 0) + s.get('downshifts_good', 0) + recent_scores.append(int(good / total * 100) if total > 0 else 100) if total_drives == 0: return "Start driving to see your stats! Time to earn your first waddle KP." diff --git a/selfdrive/ui/mici/onroad/manual_stats_widget.py b/selfdrive/ui/mici/onroad/manual_stats_widget.py index 42e6a7d0bb3fb7..e8aed06d9b2235 100644 --- a/selfdrive/ui/mici/onroad/manual_stats_widget.py +++ b/selfdrive/ui/mici/onroad/manual_stats_widget.py @@ -249,8 +249,5 @@ def _draw_rpm_meter(self, x: int, y: int, w: int, h: int, rpm: float, cs): def _load_stats(self): """Load current session stats""" - try: - data = self._params.get("ManualDriveLiveStats") - self._stats = data if data else {} - except Exception: - self._stats = {} + data = self._params.get("ManualDriveLiveStats") + self._stats = data if data else {} From 091f686a2bf73c771799c737381a0f38d12c948c Mon Sep 17 00:00:00 2001 From: Shane Smiskol Date: Sat, 7 Feb 2026 22:12:18 -0800 Subject: [PATCH 17/23] fix launching --- selfdrive/ui/mici/onroad/manual_stats_widget.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/selfdrive/ui/mici/onroad/manual_stats_widget.py b/selfdrive/ui/mici/onroad/manual_stats_widget.py index e8aed06d9b2235..1629146376e0e9 100644 --- a/selfdrive/ui/mici/onroad/manual_stats_widget.py +++ b/selfdrive/ui/mici/onroad/manual_stats_widget.py @@ -137,8 +137,7 @@ def _render(self, rect: rl.Rectangle): # === 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: + if self._stats.get('is_launching', False): 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 From 8565338f208f3224b7d98ca11919d62529180098 Mon Sep 17 00:00:00 2001 From: Shane Smiskol Date: Sat, 7 Feb 2026 22:39:38 -0800 Subject: [PATCH 18/23] aggregate by day not drive --- opendbc_repo | 2 +- .../ui/mici/layouts/settings/manual_stats.py | 153 +++++++++++------- 2 files changed, 95 insertions(+), 60 deletions(-) diff --git a/opendbc_repo b/opendbc_repo index ce6dc0eab68e8c..e26d58bc77d979 160000 --- a/opendbc_repo +++ b/opendbc_repo @@ -1 +1 @@ -Subproject commit ce6dc0eab68e8ce9e3f5bae9e5623c98f5193f8a +Subproject commit e26d58bc77d979486ff7251d56d6ce430d062b92 diff --git a/selfdrive/ui/mici/layouts/settings/manual_stats.py b/selfdrive/ui/mici/layouts/settings/manual_stats.py index d92dd3ca8502a2..2e5f7f81f01af1 100644 --- a/selfdrive/ui/mici/layouts/settings/manual_stats.py +++ b/selfdrive/ui/mici/layouts/settings/manual_stats.py @@ -4,6 +4,7 @@ Shows historical stats and trends for manual transmission driving. """ +import datetime import json import pyray as rl @@ -121,30 +122,32 @@ def _render(self, rect: rl.Rectangle): ]) y += 15 - # Trend card - derive from session_history + # Trend card - aggregate by day for consistency with charts session_history = self._stats.get('session_history', []) - recent_sessions = session_history[-10:] - recent_stalls = [s.get('stalls', 0) for s in recent_sessions] + recent_days = self._aggregate_by_day(session_history)[-10:] + num_days = len(recent_days) + + recent_stalls = [d.get('stalls', 0) for d in recent_days] recent_shifts = [] - for s in recent_sessions: - total = s.get('upshifts', 0) + s.get('downshifts', 0) - good = s.get('upshifts_good', 0) + s.get('downshifts_good', 0) + for d in recent_days: + total = d.get('upshifts', 0) + d.get('downshifts', 0) + good = d.get('upshifts_good', 0) + d.get('downshifts_good', 0) recent_shifts.append(int(good / total * 100) if total > 0 else 100) 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)) + trend_items.append((f"Stall Trend (last {num_days}d)", 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)) + trend_items.append((f"Shift Score Trend (last {num_days}d)", 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))) + trend_items.append((f"Avg Shift Score (last {num_days}d)", f"{int(avg_score)}/100", self._score_color(avg_score))) if trend_items: y = self._draw_card(x, y, w, "Recent Trends", trend_items) @@ -218,9 +221,32 @@ def _draw_card(self, x: int, y: int, w: int, title: str, items: list) -> int: return y + def _aggregate_by_day(self, sessions: list) -> list: + """Aggregate sessions into per-day summaries, summing counts""" + import math + days: dict[str, dict] = {} # date_str -> aggregated dict + for s in sessions: + ts = s.get('timestamp', 0) + if not ts or not isinstance(ts, (int, float)) or math.isnan(ts) or ts <= 0: + continue + date_key = datetime.datetime.fromtimestamp(ts).strftime('%Y-%m-%d') + if date_key not in days: + days[date_key] = { + 'timestamp': ts, # Keep last timestamp for the day label + 'duration': 0, 'stalls': 0, 'lugs': 0, + 'upshifts': 0, 'upshifts_good': 0, + 'downshifts': 0, 'downshifts_good': 0, + 'launches': 0, 'launches_good': 0, + } + d = days[date_key] + d['timestamp'] = max(d['timestamp'], ts) + for k in ('duration', 'stalls', 'lugs', 'upshifts', 'upshifts_good', + 'downshifts', 'downshifts_good', 'launches', 'launches_good'): + d[k] = d.get(k, 0) + s.get(k, 0) + return list(days.values()) + def _draw_shift_chart(self, x: int, y: int, w: int, sessions: list) -> int: - """Draw a bar chart showing shift score history""" - import datetime + """Draw a bar chart showing shift score history (aggregated by day)""" font_bold = gui_app.font(FontWeight.BOLD) font_small = gui_app.font(FontWeight.ROMAN) @@ -245,30 +271,31 @@ def _draw_shift_chart(self, x: int, y: int, w: int, sessions: list) -> int: 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: + days = self._aggregate_by_day(sessions) + display_days = days[-12:] if len(days) > 12 else days + if not display_days: return y + chart_h bar_spacing = 4 - bar_w = max(8, (chart_w - bar_spacing * len(display_sessions)) // len(display_sessions)) + bar_w = max(8, (chart_w - bar_spacing * len(display_days)) // len(display_days)) - 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) + for i, day in enumerate(display_days): + ups = day.get('upshifts', 0) + ups_good = day.get('upshifts_good', 0) + downs = day.get('downshifts', 0) + downs_good = day.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 + bar_y_top = 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) + rl.draw_rectangle(int(bar_x), int(bar_y_top), int(bar_w), int(bar_h), color) # Day label - timestamp = session.get('timestamp', 0) + timestamp = day.get('timestamp', 0) if timestamp > 0: dt = datetime.datetime.fromtimestamp(timestamp) day_x = bar_x + bar_w // 2 - 4 @@ -281,8 +308,7 @@ def _draw_shift_chart(self, x: int, y: int, w: int, sessions: list) -> int: 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 + """Draw a bar chart showing stalls and lugs per day""" font_bold = gui_app.font(FontWeight.BOLD) font_small = gui_app.font(FontWeight.ROMAN) @@ -298,9 +324,11 @@ def _draw_stalls_chart(self, x: int, y: int, w: int, sessions: list) -> int: chart_w = w - 60 chart_inner_h = 70 + days = self._aggregate_by_day(sessions) + display_days = days[-12:] if len(days) > 12 else days + # 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((d.get('stalls', 0) + d.get('lugs', 0) for d in display_days), default=1) if display_days else 1 max_issues = max(max_issues, 5) # Min scale of 5 # Draw axis @@ -311,15 +339,15 @@ def _draw_stalls_chart(self, x: int, y: int, w: int, sessions: list) -> int: 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: + if not display_days: return y + chart_h bar_spacing = 4 - bar_w = max(8, (chart_w - bar_spacing * len(display_sessions)) // len(display_sessions)) + bar_w = max(8, (chart_w - bar_spacing * len(display_days)) // len(display_days)) - for i, session in enumerate(display_sessions): - stalls = session.get('stalls', 0) - lugs = session.get('lugs', 0) + for i, day in enumerate(display_days): + stalls = day.get('stalls', 0) + lugs = day.get('lugs', 0) bar_x = chart_x + i * (bar_w + bar_spacing) # Stacked bar: stalls (red) on bottom, lugs (orange) on top @@ -335,7 +363,7 @@ def _draw_stalls_chart(self, x: int, y: int, w: int, sessions: list) -> int: 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) + timestamp = day.get('timestamp', 0) if timestamp > 0: dt = datetime.datetime.fromtimestamp(timestamp) day_x = bar_x + bar_w // 2 - 4 @@ -352,8 +380,7 @@ def _draw_stalls_chart(self, x: int, y: int, w: int, sessions: list) -> int: 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 + """Draw a bar chart showing launch success rate per day""" font_bold = gui_app.font(FontWeight.BOLD) font_small = gui_app.font(FontWeight.ROMAN) @@ -377,30 +404,31 @@ def _draw_launch_chart(self, x: int, y: int, w: int, sessions: list) -> int: 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: + days = self._aggregate_by_day(sessions) + display_days = days[-12:] if len(days) > 12 else days + if not display_days: return y + chart_h bar_spacing = 4 - bar_w = max(8, (chart_w - bar_spacing * len(display_sessions)) // len(display_sessions)) + bar_w = max(8, (chart_w - bar_spacing * len(display_days)) // len(display_days)) - for i, session in enumerate(display_sessions): - launches = session.get('launches', 0) - launches_good = session.get('launches_good', 0) + for i, day in enumerate(display_days): + launches = day.get('launches', 0) + launches_good = day.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 + bar_y_top = 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) + rl.draw_rectangle(int(bar_x), int(bar_y_top), 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) + timestamp = day.get('timestamp', 0) if timestamp > 0: dt = datetime.datetime.fromtimestamp(timestamp) day_x = bar_x + bar_w // 2 - 4 @@ -582,12 +610,13 @@ def _get_overall_hand(self) -> tuple[str, rl.Color]: # Calculate overall score score = shift_pct - (stall_rate * 10) - # Recent improvement bonus - derive from session_history + # Recent improvement bonus - aggregate by day session_history = self._stats.get('session_history', []) + recent_days = self._aggregate_by_day(session_history)[-10:] recent_scores = [] - for s in session_history[-10:]: - total = s.get('upshifts', 0) + s.get('downshifts', 0) - good = s.get('upshifts_good', 0) + s.get('downshifts_good', 0) + for d in recent_days: + total = d.get('upshifts', 0) + d.get('downshifts', 0) + good = d.get('upshifts_good', 0) + d.get('downshifts_good', 0) recent_scores.append(int(good / total * 100) if total > 0 else 100) if len(recent_scores) >= 3: if recent_scores[-1] > recent_scores[0]: @@ -620,20 +649,26 @@ 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) - # Derive recent trends from session_history + # Aggregate by day for consistent messaging session_history = self._stats.get('session_history', []) - recent_sessions = session_history[-10:] - recent_stalls = [s.get('stalls', 0) for s in recent_sessions] + recent_days = self._aggregate_by_day(session_history)[-10:] + num_days = len(recent_days) + recent_stalls = [d.get('stalls', 0) for d in recent_days] recent_scores = [] - for s in recent_sessions: - total = s.get('upshifts', 0) + s.get('downshifts', 0) - good = s.get('upshifts_good', 0) + s.get('downshifts_good', 0) + for d in recent_days: + total = d.get('upshifts', 0) + d.get('downshifts', 0) + good = d.get('upshifts_good', 0) + d.get('downshifts_good', 0) recent_scores.append(int(good / total * 100) if total > 0 else 100) if total_drives == 0: 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 + if total_drives <= 2: + if total_stalls == 0: + return "No stalls yet! Waddle energy from day 1. Keep it up!" + return f"{total_stalls} stall{'s' if total_stalls > 1 else ''} so far - every waddle driver starts somewhere. QG!" + + stall_rate = total_stalls / total_drives # Check for improvement improving = False @@ -646,16 +681,16 @@ def _get_encouragement(self) -> str: if recent_avg == 0: # 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!" + return f"Last {num_days}d: 95%+ shifts, 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!" + return f"Last {num_days}d: no stalls AND improving? Waddle energy! QG to KP!" + return f"Last {num_days}d: no stalls - waddle game strong! Not SS, priest-approved!" elif recent_avg < stall_rate: - return "Recent drives better than avg - shedding jackets, channeling waddle!" + return f"Last {num_days}d: better than avg - shedding jackets, channeling waddle!" if stall_rate < 0.5: if improving: - return "< 1 stall per 2 drives AND improving! Porch-worthy waddle progress!" + return f"< 1 stall per 2 drives AND improving (last {num_days}d)! Porch-worthy waddle progress!" return "< 1 stall per 2 drives - solid waddle vibes, not SS!" elif stall_rate < 1: return "~1 stall per drive - de-jacketing in progress!" From 993e9e22de7ec80e93dad306aabab3ad4e8d7de9 Mon Sep 17 00:00:00 2001 From: Shane Smiskol Date: Sat, 7 Feb 2026 22:59:40 -0800 Subject: [PATCH 19/23] big mici ui --- .../ui/mici/onroad/manual_stats_widget.py | 77 ++++++++++--------- 1 file changed, 41 insertions(+), 36 deletions(-) diff --git a/selfdrive/ui/mici/onroad/manual_stats_widget.py b/selfdrive/ui/mici/onroad/manual_stats_widget.py index 1629146376e0e9..f3a8d8ae5e55a5 100644 --- a/selfdrive/ui/mici/onroad/manual_stats_widget.py +++ b/selfdrive/ui/mici/onroad/manual_stats_widget.py @@ -76,27 +76,27 @@ def _render(self, rect: rl.Rectangle): if not cs: return - # Widget dimensions - extend to bottom with same margin as top + # Widget dimensions - full width with equal margins 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) + w = int(rect.width - 2 * margin) + h = int(rect.height - 2 * margin) + x = int(rect.x + margin) y = int(rect.y + margin) # Background - rl.draw_rectangle_rounded(rl.Rectangle(x, y, w, h), 0.08, 10, BG_COLOR) + rl.draw_rectangle_rounded(rl.Rectangle(x, y, w, h), 0.2, 10, BG_COLOR) font = gui_app.font(FontWeight.MEDIUM) font_bold = gui_app.font(FontWeight.BOLD) - px = x + 14 - py = y + 12 + px = x + 16 + py = y + 2 - # === RPM METER === + # === RPM METER (top, full width) === rpm = cs.engineRpm - self._draw_rpm_meter(px, py, w - 28, 50, rpm, cs) - py += 62 + self._draw_rpm_meter(px, py, w - 32, 60, rpm, cs) + py += 64 - # === GEAR + SHIFT GRADE FLASH === + # === GEAR (left) + RPM NUMBER (right) on same line === gear = cs.gearActual gear_text = str(gear) if gear > 0 else "N" @@ -120,35 +120,43 @@ def _render(self, rect: rl.Rectangle): else: gear_color = RED grade_text = "✗" - 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) + rl.draw_text_ex(font_bold, gear_text, rl.Vector2(px, py), 66, 0, gear_color) + rl.draw_text_ex(font_bold, grade_text, rl.Vector2(px + 50, py + 10), 48, 0, gear_color) else: - rl.draw_text_ex(font_bold, gear_text, rl.Vector2(px, py), 55, 0, WHITE) + rl.draw_text_ex(font_bold, gear_text, rl.Vector2(px, py), 66, 0, WHITE) # Shift suggestion arrow suggestion = self._stats.get('shift_suggestion', 'ok') if suggestion == 'upshift': - rl.draw_text_ex(font_bold, "↑", rl.Vector2(px + 95, py + 8), 43, 0, GREEN) + rl.draw_text_ex(font_bold, "↑", rl.Vector2(px + 115, py + 10), 52, 0, GREEN) elif suggestion == 'downshift': - rl.draw_text_ex(font_bold, "↓", rl.Vector2(px + 95, py + 8), 43, 0, YELLOW) + rl.draw_text_ex(font_bold, "↓", rl.Vector2(px + 115, py + 10), 52, 0, YELLOW) - py += 62 + # RPM number (right-aligned, same line as gear) + rpm_text = f"{int(round(self._rpm_filter.x / 10) * 10)}" + rpm_width = rl.measure_text_ex(font_bold, rpm_text, 44, 0).x + rpm_label_width = rl.measure_text_ex(font, "rpm", 22, 0).x + rpm_right = x + w - 16 + rl.draw_text_ex(font_bold, rpm_text, rl.Vector2(rpm_right - rpm_width - rpm_label_width - 22, py + 22), 44, 0, WHITE) + rl.draw_text_ex(font, "rpm", rl.Vector2(rpm_right - rpm_label_width, py + 42), 22, 0, GRAY) + + py += 68 # === LAUNCH FEEDBACK === launches = self._stats.get('launches', 0) good_launches = self._stats.get('good_launches', 0) if self._stats.get('is_launching', False): - rl.draw_text_ex(font, "LAUNCHING...", rl.Vector2(px, py), 26, 0, CYAN) + rl.draw_text_ex(font, "LAUNCHING...", rl.Vector2(px, py), 31, 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), 26, 0, color) + rl.draw_text_ex(font, f"Launch: {good_launches}/{launches}", rl.Vector2(px, py), 31, 0, color) else: - rl.draw_text_ex(font, "Launch: -", rl.Vector2(px, py), 26, 0, GRAY) - py += 34 + rl.draw_text_ex(font, "Launch: -", rl.Vector2(px, py), 31, 0, GRAY) + py += 36 # === STATS ROW === - font_size = 24 + font_size = 29 # Stalls & Lugs on same line stalls = self._stats.get('stalls', 0) @@ -161,7 +169,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 + 65, py), font_size, 0, lug_color) + rl.draw_text_ex(font, f"L:{lugs}", rl.Vector2(px + 78, py), font_size, 0, lug_color) # Shift quality shifts = self._stats.get('shifts', 0) @@ -169,18 +177,18 @@ 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 + 135, py), font_size, 0, color) + rl.draw_text_ex(font, f"Sh:{pct}%", rl.Vector2(px + 162, py), font_size, 0, color) else: - rl.draw_text_ex(font, "Sh:-", rl.Vector2(px + 135, py), font_size, 0, GRAY) + rl.draw_text_ex(font, "Sh:-", rl.Vector2(px + 162, 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 (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)) + # Bar at top, taller + bar_h = 56 + bar_y = y + 4 + rl.draw_rectangle_rounded(rl.Rectangle(x, bar_y, w, bar_h), 0.2, 5, rl.Color(40, 40, 40, 200)) # Calculate fill width rpm_pct = min(rpm / RPM_REDLINE, 1.0) @@ -227,24 +235,21 @@ 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) - 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_text_ex(font, f"{int(round(down_rpm / 10) * 10)}!", rl.Vector2(down_x - 54, bar_y + bar_h + 4), 24, 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_text_ex(font, f"{int(round(down_rpm / 10) * 10)}", rl.Vector2(down_x - 24, bar_y + bar_h + 4), 24, 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_text_ex(font, f"{int(round(up_rpm / 10) * 10)}", rl.Vector2(up_x - 24, bar_y + bar_h + 4), 24, 0, white) - # RPM text (filtered for smooth display, rounded to nearest 10) + # Update RPM filter (text drawn in main render next to gear) 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 bf0870d6993f46451cd9cc3c2b7a8eb8cf18b0f8 Mon Sep 17 00:00:00 2001 From: Shane Smiskol Date: Sat, 7 Feb 2026 23:14:56 -0800 Subject: [PATCH 20/23] randomize drive summary texts --- selfdrive/ui/mici/layouts/main.py | 6 +- .../ui/mici/layouts/manual_drive_summary.py | 117 ++++++++++++------ 2 files changed, 82 insertions(+), 41 deletions(-) diff --git a/selfdrive/ui/mici/layouts/main.py b/selfdrive/ui/mici/layouts/main.py index ca27592f2b1316..365a769320cf28 100644 --- a/selfdrive/ui/mici/layouts/main.py +++ b/selfdrive/ui/mici/layouts/main.py @@ -36,9 +36,6 @@ 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() @@ -150,8 +147,7 @@ def _show_drive_summary_if_available(self): session.get('upshifts', 0) > 0 or session.get('launches', 0) > 0) if duration > 30 and has_activity: - self._drive_summary_dialog = ManualDriveSummaryDialog() - gui_app.set_modal_overlay(self._drive_summary_dialog) + gui_app.set_modal_overlay(ManualDriveSummaryDialog()) def _set_mode_for_started(self, onroad_transition: bool = False): if ui_state.started: diff --git a/selfdrive/ui/mici/layouts/manual_drive_summary.py b/selfdrive/ui/mici/layouts/manual_drive_summary.py index d264bdec8e7d44..d7f853d04d6fbf 100644 --- a/selfdrive/ui/mici/layouts/manual_drive_summary.py +++ b/selfdrive/ui/mici/layouts/manual_drive_summary.py @@ -7,6 +7,7 @@ """ import json +import random import pyray as rl from typing import Optional, Callable @@ -62,14 +63,12 @@ def __init__(self, dismiss_callback: Optional[Callable] = None): # Load data immediately since show_event may not be called for modals self._load_session() self._load_historical() + # Pick random texts once for this instance + self._header_text, self._header_color = self._pick_header() + self._encouragement_text = self._pick_encouragement() # 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._load_session() - self._load_historical() - def _load_session(self): """Load the last session data from session_history in ManualDriveStats""" data = self._params.get("ManualDriveStats") @@ -156,17 +155,34 @@ def _calculate_grade(self): 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""" + def _pick_header(self) -> tuple[str, rl.Color]: if self._overall_grade == "good": - return "Waddle Driver!", GREEN + return random.choice([ + "Waddle Driver!", + "KP Earned!", + "Porch-worthy!", + "CCR Energy!", + "Priest-approved!", + "Pure Waddle!", + ]), GREEN elif self._overall_grade == "ok": - return "Decent Drive", YELLOW + return random.choice([ + "Decent Drive", + "Getting There!", + "Not SS... Yet", + "Shedding Jackets", + "Almost Waddle", + ]), YELLOW else: - return "Jackets...", RED - - def _get_encouragement_text(self) -> str: - """Get encouragement or criticism text based on performance""" + return random.choice([ + "Jackets...", + "Huge Oof", + "SS Vibes", + "Full Jackets!", + "Jacketed!", + ]), RED + + def _pick_encouragement(self) -> str: if not self._session_data: return "No data available for this drive." @@ -191,48 +207,77 @@ def _get_encouragement_text(self) -> str: 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!") + messages.append(random.choice([ + "PERFECT! Waddle is driving! Kacper threw his glasses!", + "FLAWLESS! Even Kacper couldn't believe it!", + "LEGENDARY! Full waddle, zero jackets, KP maxed!", + ])) elif self._card_rank == "A": - messages.append("Aces! Porch-worthy waddle, KP earned!") + messages.append(random.choice([ + "Aces! Porch-worthy waddle, KP earned!", + "Aces! CCR material right here!", + "Aces! Waddle would be proud!", + ])) elif self._card_rank == "K": - messages.append("Kings! Waddle energy, CCM vibes!") + messages.append(random.choice([ + "Kings! Waddle energy, CCM vibes!", + "Kings! Solid drive, almost porch-worthy!", + "Kings! Not SS, definitely QG!", + ])) if stalls == 0 and launch_stalled == 0: - messages.append("No stalls!") + messages.append(random.choice(["No stalls!", "Zero stalls, clean!", "Stall-free!"])) if perfect_shifts: - messages.append("Perfect shifts - priest-approved!") + messages.append(random.choice([ + "Perfect shifts - priest-approved!", + "Every shift was butter!", + "Flawless shifting, pure waddle!", + ])) elif upshift_total > 0 and upshift_good == upshift_total: - messages.append("Perfect upshifts!") + messages.append(random.choice(["Perfect upshifts!", "Upshifts on point!", "Clean upshifts!"])) if downshift_total > 0 and downshift_good >= downshift_total * 0.8: - messages.append("Great rev matching!") + messages.append(random.choice(["Great rev matching!", "Rev matching on point!", "Heel-toe vibes!"])) if perfect_launches: - messages.append("Flawless launches!") + messages.append(random.choice(["Flawless launches!", "Every launch was smooth!", "Launch game maxed!"])) elif launch_total > 0 and launch_good >= launch_total * 0.8: - messages.append("Smooth launches!") + messages.append(random.choice(["Smooth launches!", "Launches looking clean!", "Good clutch control!"])) if not messages: - messages.append("Keep channeling waddle!") + messages.append(random.choice(["Keep channeling waddle!", "Waddle energy maintained!", "Stay on this path!"])) elif self._overall_grade == "ok": if self._card_rank == "Q": - messages.append("Queens - almost there!") + messages.append(random.choice([ + "Queens - almost there!", + "Queens - one step from waddle!", + "Queens - so close to KP!", + ])) else: - messages.append("Jacks - improving, not SS!") + messages.append(random.choice([ + "Jacks - improving, not SS!", + "Jacks - shedding jackets slowly!", + "Jacks - waddle is within reach!", + ])) if stalls > 0: - messages.append(f"Only {stalls} stall{'s' if stalls > 1 else ''} - shedding jackets!") + messages.append(f"Only {stalls} stall{'s' if stalls > 1 else ''} - {random.choice(['shedding jackets!', 'getting better!', 'less than before?'])}") if lugs > 0: - messages.append(f"Watch RPMs - {lugs} lug{'s' if lugs > 1 else ''}.") + messages.append(f"{random.choice(['Watch RPMs', 'Easy on the low RPMs'])} - {lugs} lug{'s' if lugs > 1 else ''}.") if upshift_total > 0 and upshift_good < upshift_total: - messages.append("Smoother upshifts needed.") + messages.append(random.choice(["Smoother upshifts needed.", "Upshifts could be cleaner.", "Work on those upshifts!"])) else: # poor - jackets - messages.append("Jacketed! Huge oof. SS vibes!") + messages.append(random.choice([ + "Jacketed! Huge oof. SS vibes!", + "Full jackets! CCR this is not.", + "Oof. Jacket city. QG needed!", + "Jacketed hard. Waddle disapproves.", + ])) if stalls > 2: - messages.append(f"{stalls} stalls - more gas, slower clutch!") + messages.append(f"{stalls} stalls - {random.choice(['more gas, slower clutch!', 'find that bite point!', 'easy on the clutch!'])}") if launch_stalled > 0: - messages.append(f"{launch_stalled} stalled launch{'es' if launch_stalled > 1 else ''} - find bite point!") + messages.append(f"{launch_stalled} stalled launch{'es' if launch_stalled > 1 else ''} - {random.choice(['find bite point!', 'more revs before release!', 'hold clutch longer!'])}") if lugs > 3: - messages.append(f"Lugging {lugs}x - downshift sooner!") + messages.append(f"Lugging {lugs}x - {random.choice(['downshift sooner!', 'drop a gear!', 'RPMs too low!'])}") if not messages[1:]: - messages.append("Even the best got jacketed at first. QG!") + messages.append(random.choice(["Even the best got jacketed at first. QG!", "Keep practicing, waddle awaits!", "Every driver starts here. KP is coming!"])) return " ".join(messages) @@ -246,7 +291,7 @@ def _measure_content_height(self) -> int: h += 75 # Shift score bar h += 195 # Stats card # Encouragement text (estimate) - encouragement = self._get_encouragement_text() + encouragement = self._encouragement_text wrapped = wrap_text(font_roman, encouragement, 22, 500) h += len(wrapped) * 28 + 20 return h @@ -273,7 +318,7 @@ def _render(self, rect: rl.Rectangle): 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() + header_text, header_color = self._header_text, self._header_color rl.draw_text_ex(font_bold, header_text, rl.Vector2(x + 15, y + 12), 44, 0, header_color) y += 58 @@ -338,7 +383,7 @@ def _render(self, rect: rl.Rectangle): y += 200 # Encouragement/criticism text - encouragement = self._get_encouragement_text() + encouragement = self._encouragement_text wrapped = wrap_text(font_roman, encouragement, 22, w) for line in wrapped: rl.draw_text_ex(font_roman, line, rl.Vector2(x, y), 22, 0, LIGHT_GRAY) From bb45e4f4cb76c24784136b356d83585ac7a94ff2 Mon Sep 17 00:00:00 2001 From: Shane Smiskol Date: Sat, 7 Feb 2026 23:19:52 -0800 Subject: [PATCH 21/23] clean up summary --- .../ui/mici/layouts/manual_drive_summary.py | 126 +++++------------- 1 file changed, 37 insertions(+), 89 deletions(-) diff --git a/selfdrive/ui/mici/layouts/manual_drive_summary.py b/selfdrive/ui/mici/layouts/manual_drive_summary.py index d7f853d04d6fbf..5c5bd1ecee5d7a 100644 --- a/selfdrive/ui/mici/layouts/manual_drive_summary.py +++ b/selfdrive/ui/mici/layouts/manual_drive_summary.py @@ -9,10 +9,10 @@ import json import random import pyray as rl -from typing import Optional, Callable +from typing import Optional from openpilot.common.params import Params -from openpilot.system.ui.lib.application import gui_app, FontWeight, FONT_SCALE +from openpilot.system.ui.lib.application import gui_app, FontWeight 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 NavWidget @@ -22,11 +22,9 @@ 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) 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 @@ -50,58 +48,50 @@ class ManualDriveSummaryDialog(NavWidget): """Modal dialog showing end-of-drive manual transmission stats""" - def __init__(self, dismiss_callback: Optional[Callable] = None): + def __init__(self): super().__init__() - self._params = Params() 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 - # Load data immediately since show_event may not be called for modals - self._load_session() - self._load_historical() + + # Load all data from one param read + self._load_data() + # Pick random texts once for this instance self._header_text, self._header_color = self._pick_header() self._encouragement_text = self._pick_encouragement() - # Set back callback to dismiss modal + self.set_back_callback(lambda: gui_app.set_modal_overlay(None)) - def _load_session(self): - """Load the last session data from session_history in ManualDriveStats""" - data = self._params.get("ManualDriveStats") - if data: - stats = data if isinstance(data, dict) else json.loads(data) - history = stats.get('session_history', []) - if history: - self._session_data = history[-1] - self._calculate_grade() - return - self._session_data = None - - def _load_historical(self): - """Load historical stats for comparison""" - 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) - else: - self._historical_data = None + def _load_data(self): + """Load session and historical data from ManualDriveStats (single read)""" + data = Params().get("ManualDriveStats") + if not data: + return + + stats = json.loads(data) + history = stats.get('session_history', []) + + # Last session + if history: + self._session_data = history[-1] + self._calculate_grade() + + # Average shift score from recent history + scores = [] + for s in history[-10:]: + 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) def _calculate_grade(self): """Calculate overall grade based on session performance""" @@ -291,8 +281,7 @@ def _measure_content_height(self) -> int: h += 75 # Shift score bar h += 195 # Stats card # Encouragement text (estimate) - encouragement = self._encouragement_text - wrapped = wrap_text(font_roman, encouragement, 22, 500) + wrapped = wrap_text(font_roman, self._encouragement_text, 22, 500) h += len(wrapped) * 28 + 20 return h @@ -318,8 +307,7 @@ def _render(self, rect: rl.Rectangle): rl.draw_rectangle_rounded(rl.Rectangle(x, y, w, top_card_h), 0.02, 10, BG_CARD) # Header - header_text, header_color = self._header_text, self._header_color - rl.draw_text_ex(font_bold, header_text, rl.Vector2(x + 15, y + 12), 44, 0, header_color) + rl.draw_text_ex(font_bold, self._header_text, rl.Vector2(x + 15, y + 12), 44, 0, self._header_color) y += 58 # Card rank display - poker hand style with subtitle @@ -383,8 +371,7 @@ def _render(self, rect: rl.Rectangle): y += 200 # Encouragement/criticism text - encouragement = self._encouragement_text - wrapped = wrap_text(font_roman, encouragement, 22, w) + wrapped = wrap_text(font_roman, self._encouragement_text, 22, w) for line in wrapped: rl.draw_text_ex(font_roman, line, rl.Vector2(x, y), 22, 0, LIGHT_GRAY) y += 28 @@ -464,42 +451,3 @@ def _draw_mini_stat(self, x: int, y: int, w: int, label: str, value, target, low 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: - """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 From 727a1c30c0787166d316f6221be79e3d6b596a6e Mon Sep 17 00:00:00 2001 From: Shane Smiskol Date: Sat, 7 Feb 2026 23:40:52 -0800 Subject: [PATCH 22/23] more random wx --- selfdrive/ui/mici/layouts/main.py | 3 +- .../ui/mici/layouts/manual_drive_summary.py | 9 +- .../ui/mici/layouts/settings/manual_stats.py | 180 ++++++++++++++---- 3 files changed, 154 insertions(+), 38 deletions(-) diff --git a/selfdrive/ui/mici/layouts/main.py b/selfdrive/ui/mici/layouts/main.py index 365a769320cf28..b308e70c2fdd15 100644 --- a/selfdrive/ui/mici/layouts/main.py +++ b/selfdrive/ui/mici/layouts/main.py @@ -1,4 +1,3 @@ -import json import pyray as rl from enum import IntEnum import cereal.messaging as messaging @@ -136,7 +135,7 @@ def _show_drive_summary_if_available(self): data = self._params.get("ManualDriveStats") if not data: return - stats = data if isinstance(data, dict) else json.loads(data) + stats = data history = stats.get('session_history', []) if not history: return diff --git a/selfdrive/ui/mici/layouts/manual_drive_summary.py b/selfdrive/ui/mici/layouts/manual_drive_summary.py index 5c5bd1ecee5d7a..4f5535fc47677d 100644 --- a/selfdrive/ui/mici/layouts/manual_drive_summary.py +++ b/selfdrive/ui/mici/layouts/manual_drive_summary.py @@ -6,7 +6,6 @@ Poker hand themed with waddle/jacket references. """ -import json import random import pyray as rl from typing import Optional @@ -72,7 +71,7 @@ def _load_data(self): if not data: return - stats = json.loads(data) + stats = data history = stats.get('session_history', []) # Last session @@ -201,18 +200,21 @@ def _pick_encouragement(self) -> str: "PERFECT! Waddle is driving! Kacper threw his glasses!", "FLAWLESS! Even Kacper couldn't believe it!", "LEGENDARY! Full waddle, zero jackets, KP maxed!", + "PERFECT! Weixing just shed a tear of joy. Kirby is star-spinning.", ])) elif self._card_rank == "A": messages.append(random.choice([ "Aces! Porch-worthy waddle, KP earned!", "Aces! CCR material right here!", "Aces! Waddle would be proud!", + "Aces! Weixing raised an eyebrow, in a good way. Kirby did a little twirl.", ])) elif self._card_rank == "K": messages.append(random.choice([ "Kings! Waddle energy, CCM vibes!", "Kings! Solid drive, almost porch-worthy!", "Kings! Not SS, definitely QG!", + "Kings! Weixing didn't complain. For Weixing, that's a compliment. Kirby is chilling.", ])) if stalls == 0 and launch_stalled == 0: messages.append(random.choice(["No stalls!", "Zero stalls, clean!", "Stall-free!"])) @@ -239,12 +241,14 @@ def _pick_encouragement(self) -> str: "Queens - almost there!", "Queens - one step from waddle!", "Queens - so close to KP!", + "Queens - Weixing checked his watch. Kirby yawned.", ])) else: messages.append(random.choice([ "Jacks - improving, not SS!", "Jacks - shedding jackets slowly!", "Jacks - waddle is within reach!", + "Jacks - Weixing pinched the bridge of his nose. Kirby deflated.", ])) if stalls > 0: messages.append(f"Only {stalls} stall{'s' if stalls > 1 else ''} - {random.choice(['shedding jackets!', 'getting better!', 'less than before?'])}") @@ -259,6 +263,7 @@ def _pick_encouragement(self) -> str: "Full jackets! CCR this is not.", "Oof. Jacket city. QG needed!", "Jacketed hard. Waddle disapproves.", + "Weixing pretends he doesn't know you. Kirby swallowed the car whole.", ])) if stalls > 2: messages.append(f"{stalls} stalls - {random.choice(['more gas, slower clutch!', 'find that bite point!', 'easy on the clutch!'])}") diff --git a/selfdrive/ui/mici/layouts/settings/manual_stats.py b/selfdrive/ui/mici/layouts/settings/manual_stats.py index 2e5f7f81f01af1..700c1601f6c22f 100644 --- a/selfdrive/ui/mici/layouts/settings/manual_stats.py +++ b/selfdrive/ui/mici/layouts/settings/manual_stats.py @@ -5,7 +5,7 @@ """ import datetime -import json +import random import pyray as rl from openpilot.common.params import Params @@ -34,6 +34,9 @@ def __init__(self, back_callback): self._params = Params() self._scroll_panel = GuiScrollPanel2(horizontal=False) self._stats: dict = {} + self._hand_text: str = "" + self._hand_color: rl.Color = GRAY + self._encouragement_text: str = "" self.set_back_callback(back_callback) def show_event(self): @@ -42,12 +45,15 @@ def show_event(self): self._load_stats() def _load_stats(self): - """Load historical stats from Params""" + """Load historical stats from Params and cache random text picks""" data = self._params.get("ManualDriveStats") if data: - self._stats = data if isinstance(data, dict) else json.loads(data) + self._stats = data else: self._stats = {} + # Pick random texts once per page visit (not every frame) + self._hand_text, self._hand_color = self._get_overall_hand() + self._encouragement_text = self._get_encouragement() def _render(self, rect: rl.Rectangle): content_height = self._measure_content_height(rect) @@ -81,9 +87,8 @@ def _render(self, rect: rl.Rectangle): return # 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), + ("Overall Rating", self._hand_text, self._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))), @@ -172,8 +177,7 @@ def _render(self, rect: rl.Rectangle): # Encouragement based on progress (with text wrapping) y += 10 - encouragement = self._get_encouragement() - wrapped_lines = wrap_text(font_roman, encouragement, 24, w - 10) + wrapped_lines = wrap_text(font_roman, self._encouragement_text, 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 @@ -588,11 +592,11 @@ def _trend_text(self, trend: float, lower_better: bool) -> tuple[str, rl.Color]: if lower_better: if trend < 0: return "Improving!", GREEN - return "Getting worse", RED + return random.choice(["Getting worse", "Getting worse - Weixing is shaking his head. Kirby turned around."]), RED else: if trend > 0: return "Improving!", GREEN - return "Getting worse", RED + return random.choice(["Getting worse", "Getting worse - Weixing is shaking his head. Kirby turned around."]), RED def _get_overall_hand(self) -> tuple[str, rl.Color]: """Calculate overall poker hand rating based on all stats""" @@ -623,27 +627,86 @@ def _get_overall_hand(self) -> tuple[str, rl.Color]: score += 5 # Bonus for improving if score >= 98 and stall_rate == 0: - return "Royal Flush - Waddle is driving! Kacper threw his glasses!", GREEN + return random.choice([ + "Royal Flush - Waddle is driving! Kacper threw his glasses!", + "Royal Flush - Perfection! Pure waddle energy!", + "Royal Flush - Legendary! KP maxed out!", + "Royal Flush - CCR material! Waddle certified!", + "Royal Flush - Elite! Priest-approved waddle!", + "Royal Flush - Weixing just shed a tear of joy. Kirby is star-spinning.", + ]), GREEN elif score >= 95 and stall_rate == 0: - return "Royal Flush - Porch-worthy waddle! KP earned!", GREEN + return random.choice([ + "Royal Flush - Porch-worthy waddle! KP earned!", + "Royal Flush - Top-tier driving, almost flawless!", + "Royal Flush - So close to perfection! Waddle approved!", + "Royal Flush - KP is proud, keep this up!", + "Royal Flush - Premium waddle, just shy of legendary!", + "Royal Flush - Weixing put your photo on his fridge. Kirby gave you a star.", + ]), GREEN elif score >= 90: - return "Straight Flush - Elite waddle, CCM vibes!", GREEN + return random.choice([ + "Straight Flush - Elite waddle, CCM vibes!", + "Straight Flush - Near-perfect, porch is calling!", + "Straight Flush - Waddle royalty!", + "Straight Flush - Weixing raised an eyebrow, in a good way. Kirby did a little twirl.", + ]), GREEN elif score >= 85: - return "Four of a Kind - Priest-approved waddle!", GREEN + return random.choice([ + "Four of a Kind - Priest-approved waddle!", + "Four of a Kind - Strong waddle game!", + "Four of a Kind - CCR energy building!", + "Four of a Kind - Weixing almost smiled. Kirby perked up.", + ]), GREEN elif score >= 80: - return "Full House - Solid waddle, not SS!", GREEN + return random.choice([ + "Full House - Solid waddle, not SS!", + "Full House - Consistent waddle vibes!", + "Full House - QG territory!", + "Full House - Weixing didn't complain. For Weixing, that's a compliment. Kirby is chilling.", + ]), GREEN elif score >= 70: - return "Flush - Good waddle, almost KP", YELLOW + return random.choice([ + "Flush - Good waddle, almost KP", + "Flush - Getting there, waddle incoming!", + "Flush - Shedding jackets nicely!", + "Flush - Weixing looked up from his phone briefly. Kirby yawned.", + ]), YELLOW elif score >= 60: - return "Straight - Improving, not SS yet", YELLOW + return random.choice([ + "Straight - Improving, not SS yet", + "Straight - Progress! Keep pushing!", + "Straight - Jacket count dropping!", + "Straight - Weixing checked his watch. Kirby fell asleep.", + ]), YELLOW elif score >= 50: - return "Three of a Kind - Getting there, shake off jackets", YELLOW + return random.choice([ + "Three of a Kind - Getting there, shake off jackets", + "Three of a Kind - Waddle is within reach!", + "Three of a Kind - Keep at it, less jackets soon!", + "Three of a Kind - Weixing pinched the bridge of his nose. Kirby deflated.", + ]), YELLOW elif score >= 40: - return "Two Pair - Jackets territory", YELLOW + return random.choice([ + "Two Pair - Jackets territory", + "Two Pair - Room to grow, QG!", + "Two Pair - Still shedding jackets", + "Two Pair - Weixing closed his laptop and stared out the window. Kirby popped.", + ]), YELLOW elif score >= 30: - return "One Pair - Jacketed, huge oof", RED + return random.choice([ + "One Pair - Jacketed, huge oof", + "One Pair - Jacket city, but improving?", + "One Pair - SS vibes, keep practicing!", + "One Pair - Weixing blocked your number. Kirby ate your clutch.", + ]), RED else: - return "High Card - SS! Full jackets!", RED + return random.choice([ + "High Card - SS! Full jackets!", + "High Card - Jacketed hard! QG needed!", + "High Card - Waddle disapproves. Keep going!", + "High Card - Weixing pretends he doesn't know you. Kirby swallowed the car whole.", + ]), RED def _get_encouragement(self) -> str: """Get encouragement based on overall progress""" @@ -661,12 +724,24 @@ def _get_encouragement(self) -> str: recent_scores.append(int(good / total * 100) if total > 0 else 100) if total_drives == 0: - return "Start driving to see your stats! Time to earn your first waddle KP." + return random.choice([ + "Start driving to see your stats! Time to earn your first waddle KP.", + "No drives yet! Get out there and start your waddle journey!", + "Empty stats - the porch awaits your first drive!", + ]) if total_drives <= 2: if total_stalls == 0: - return "No stalls yet! Waddle energy from day 1. Keep it up!" - return f"{total_stalls} stall{'s' if total_stalls > 1 else ''} so far - every waddle driver starts somewhere. QG!" + return random.choice([ + "No stalls yet! Waddle energy from day 1. Keep it up!", + "Zero stalls early on! Natural waddle talent?!", + "Clean start! Priest-approved from the jump!", + ]) + return random.choice([ + f"{total_stalls} stall{'s' if total_stalls > 1 else ''} so far - every waddle driver starts somewhere. QG!", + f"{total_stalls} stall{'s' if total_stalls > 1 else ''} early on - totally normal, waddle is coming!", + f"{total_stalls} stall{'s' if total_stalls > 1 else ''} - shedding jackets already. Keep going!", + ]) stall_rate = total_stalls / total_drives @@ -681,18 +756,55 @@ def _get_encouragement(self) -> str: if recent_avg == 0: # Check for crazy good performance if len(recent_scores) >= 3 and all(s >= 95 for s in recent_scores[-3:]): - return f"Last {num_days}d: 95%+ shifts, NO stalls?! Waddle is driving! Kacper threw his glasses!" + return random.choice([ + f"Last {num_days}d: 95%+ shifts, NO stalls?! Waddle is driving! Kacper threw his glasses!", + f"Last {num_days}d: near-perfect shifts, zero stalls! Legendary waddle!", + f"Last {num_days}d: flawless! Porch-worthy, priest-approved, KP maxed!", + ]) if improving: - return f"Last {num_days}d: no stalls AND improving? Waddle energy! QG to KP!" - return f"Last {num_days}d: no stalls - waddle game strong! Not SS, priest-approved!" + return random.choice([ + f"Last {num_days}d: no stalls AND improving? Waddle energy! QG to KP!", + f"Last {num_days}d: stall-free and trending up! CCR energy!", + f"Last {num_days}d: zero stalls, scores climbing! Porch incoming!", + ]) + return random.choice([ + f"Last {num_days}d: no stalls - waddle game strong! Not SS, priest-approved!", + f"Last {num_days}d: stall-free! Solid waddle vibes!", + f"Last {num_days}d: clean driving, no jackets! Keep it up!", + ]) elif recent_avg < stall_rate: - return f"Last {num_days}d: better than avg - shedding jackets, channeling waddle!" - - if stall_rate < 0.5: + return random.choice([ + f"Last {num_days}d: better than avg - shedding jackets, channeling waddle!", + f"Last {num_days}d: fewer stalls than usual! De-jacketing in progress!", + f"Last {num_days}d: improving! Waddle is within reach!", + ]) + + if total_stalls == 0: + return random.choice([ + "Zero stalls overall! Waddle game is immaculate!", + "Not a single stall! Priest-approved driving!", + "Stall-free career! Pure waddle energy!", + ]) + + drives_per_stall = round(total_drives / total_stalls) + + if stall_rate < 1: if improving: - return f"< 1 stall per 2 drives AND improving (last {num_days}d)! Porch-worthy waddle progress!" - return "< 1 stall per 2 drives - solid waddle vibes, not SS!" - elif stall_rate < 1: - return "~1 stall per drive - de-jacketing in progress!" + return random.choice([ + f"1 stall every {drives_per_stall} drives AND improving (last {num_days}d)! Porch-worthy waddle progress!", + f"1 stall every {drives_per_stall} drives AND getting better (last {num_days}d)! CCR material!", + f"1 stall every {drives_per_stall} drives AND trending up (last {num_days}d)! KP earned!", + ]) + return random.choice([ + f"1 stall every {drives_per_stall} drives - solid waddle vibes, not SS!", + f"1 stall every {drives_per_stall} drives - consistent waddle energy!", + f"1 stall every {drives_per_stall} drives - jacket count staying low!", + ]) else: - return "Keep at it! Even the best got jacketed at first. QG to KP!" + stalls_per_drive = round(total_stalls / total_drives) + return random.choice([ + f"About {stalls_per_drive} stall{'s' if stalls_per_drive > 1 else ''} every drive - keep at it! QG to KP!", + f"About {stalls_per_drive} stall{'s' if stalls_per_drive > 1 else ''} every drive - still jacketed, but every drive is practice!", + f"About {stalls_per_drive} stall{'s' if stalls_per_drive > 1 else ''} every drive - jackets happen! The porch is waiting!", + f"About {stalls_per_drive} stall{'s' if stalls_per_drive > 1 else ''} every drive - Weixing left the room. Kirby followed him out.", + ]) From 4e0bcddae930caea9e15a4d17ce616e3a1ac90ea Mon Sep 17 00:00:00 2001 From: Shane Smiskol Date: Sat, 7 Feb 2026 23:43:48 -0800 Subject: [PATCH 23/23] not sure how i feel about this change, could revert --- .../ui/mici/layouts/settings/manual_stats.py | 208 +++++++++++++++++- 1 file changed, 204 insertions(+), 4 deletions(-) diff --git a/selfdrive/ui/mici/layouts/settings/manual_stats.py b/selfdrive/ui/mici/layouts/settings/manual_stats.py index 700c1601f6c22f..d8292f3c15dbfd 100644 --- a/selfdrive/ui/mici/layouts/settings/manual_stats.py +++ b/selfdrive/ui/mici/layouts/settings/manual_stats.py @@ -37,6 +37,7 @@ def __init__(self, back_callback): self._hand_text: str = "" self._hand_color: rl.Color = GRAY self._encouragement_text: str = "" + self._section_comments: dict[str, tuple[str, rl.Color]] = {} self.set_back_callback(back_callback) def show_event(self): @@ -54,6 +55,19 @@ def _load_stats(self): # Pick random texts once per page visit (not every frame) self._hand_text, self._hand_color = self._get_overall_hand() self._encouragement_text = self._get_encouragement() + self._section_comments = self._pick_section_comments() + + def _draw_comment(self, x: int, y: int, w: int, key: str) -> int: + """Draw a section comment if one exists. Returns updated y.""" + if key not in self._section_comments: + return y + text, color = self._section_comments[key] + font_roman = gui_app.font(FontWeight.ROMAN) + wrapped = wrap_text(font_roman, text, 18, w) + for line in wrapped: + rl.draw_text_ex(font_roman, line, rl.Vector2(x, y), 18, 0, color) + y += 22 + return y + 5 def _render(self, rect: rl.Rectangle): content_height = self._measure_content_height(rect) @@ -111,6 +125,7 @@ def _render(self, rect: rl.Rectangle): ("Total Downshifts", str(total_down), WHITE), ("Good Downshifts", f"{down_good} ({down_pct})", self._pct_color(down_good, total_down)), ]) + y = self._draw_comment(x, y, w, 'shifts') y += 15 # Launch quality card @@ -125,6 +140,7 @@ def _render(self, rect: rl.Rectangle): ("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 = self._draw_comment(x, y, w, 'launches') y += 15 # Trend card - aggregate by day for consistency with charts @@ -163,16 +179,20 @@ def _render(self, rect: rl.Rectangle): 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 = self._draw_comment(x, y, w, 'gears') 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 = self._draw_comment(x, y, w, 'shift_chart') y += 15 y = self._draw_stalls_chart(x, y, w, session_history) + y = self._draw_comment(x, y, w, 'stalls_chart') y += 15 y = self._draw_launch_chart(x, y, w, session_history) + y = self._draw_comment(x, y, w, 'launch_chart') y += 15 # Encouragement based on progress (with text wrapping) @@ -520,23 +540,37 @@ def _measure_content_height(self, rect: rl.Rectangle) -> int: if not self._stats or self._stats.get('total_drives', 0) == 0: return y + 40 + comment_h = 27 # height per section comment line + # Overview card (now has 5 items with hand rating, +60 for potential wrapped lines) y += 50 + 5 * 38 + 60 + 15 - # Shift card + # Shift card + comment y += 50 + 4 * 38 + 15 - # Launch card + if 'shifts' in self._section_comments: + y += comment_h + # Launch card + comment y += 50 + 3 * 38 + 15 + if 'launches' in self._section_comments: + y += comment_h # Trend card (estimate) y += 50 + 3 * 38 + 15 - # Gear chart + # Gear chart + comment if self._stats.get('gear_shift_counts'): y += 180 + 15 + if 'gears' in self._section_comments: + y += comment_h - # Charts (3 charts) + # Charts (3 charts) + comments if self._stats.get('session_history'): y += 200 + 15 # Shift score chart + if 'shift_chart' in self._section_comments: + y += comment_h y += 180 + 15 # Stalls/lugs chart + if 'stalls_chart' in self._section_comments: + y += comment_h y += 180 + 15 # Launch chart + if 'launch_chart' in self._section_comments: + y += comment_h # Encouragement (estimate 2-3 lines wrapped) y += 100 @@ -708,6 +742,172 @@ def _get_overall_hand(self) -> tuple[str, rl.Color]: "High Card - Weixing pretends he doesn't know you. Kirby swallowed the car whole.", ]), RED + def _pick_section_comments(self) -> dict[str, tuple[str, 'rl.Color']]: + """Pick a contextual comment for each section based on the data""" + comments: dict[str, tuple[str, rl.Color]] = {} + + # Shift quality + total_up = self._stats.get('total_upshifts', 0) + total_down = self._stats.get('total_downshifts', 0) + up_good = self._stats.get('upshifts_good', 0) + down_good = self._stats.get('downshifts_good', 0) + total_shifts = total_up + total_down + if total_shifts > 0: + shift_pct = (up_good + down_good) / total_shifts * 100 + if shift_pct >= 90: + comments['shifts'] = random.choice([ + "Butter smooth! Weixing almost smiled.", + "Shifts are dialed. Kirby did a little twirl.", + "Priest-approved shifting right here.", + ]), GREEN + elif shift_pct >= 70: + comments['shifts'] = random.choice([ + "Solid shifts, room to polish.", + "Getting cleaner. Kirby is watching.", + "Not bad! Weixing looked up briefly.", + ]), YELLOW + else: + comments['shifts'] = random.choice([ + "Those shifts need some love.", + "Weixing felt that from across the room.", + "Kirby is concerned about your synchros.", + ]), RED + + # Launch quality + total_launches = self._stats.get('total_launches', 0) + good_launches = self._stats.get('launches_good', 0) + stalled_launches = self._stats.get('launches_stalled', 0) + if total_launches > 0: + launch_pct = good_launches / total_launches * 100 + if launch_pct >= 90: + comments['launches'] = random.choice([ + "Smooth off the line! Clutch control on point.", + "Launch game is strong. Kirby approves.", + "Clean launches. The bite point is your friend.", + ]), GREEN + elif launch_pct >= 70: + comments['launches'] = random.choice([ + "Launches are OK. Find that bite point more consistently.", + "Getting there! A little more clutch feel needed.", + "Decent launches, some room to grow.", + ]), YELLOW + else: + comments['launches'] = random.choice([ + "Launches need work. Easy on the clutch!", + "Kirby is bracing for impact every launch.", + "More revs, slower clutch release. You'll get it.", + ]), RED + if stalled_launches > 2: + comments['launches'] = random.choice([ + f"{stalled_launches} stalled launches - find that bite point!", + f"{stalled_launches} stalled launches - Kirby is hiding the keys.", + f"{stalled_launches} stalled launches - more gas before you release!", + ]), RED + + # Gear 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()): + worst_gear, worst_score = None, 101 + best_gear, best_score = None, -1 + for gear in range(1, 7): + count = gear_counts.get(gear, gear_counts.get(str(gear), 0)) + jerk = gear_jerks.get(gear, gear_jerks.get(str(gear), 0.0)) + if count > 0: + smoothness = max(0, min(100, 100 - (jerk / count * 20))) + if smoothness < worst_score: + worst_gear, worst_score = gear, smoothness + if smoothness > best_score: + best_gear, best_score = gear, smoothness + if worst_gear and best_gear and worst_gear != best_gear: + comments['gears'] = random.choice([ + f"Gear {best_gear} is your smoothest. Gear {worst_gear} needs practice.", + f"Cleanest into gear {best_gear}. Gear {worst_gear} is your weak spot.", + f"Gear {worst_gear} is where the jackets live. Gear {best_gear} is waddle territory.", + ]), YELLOW + + # Shift score chart + session_history = self._stats.get('session_history', []) + days = self._aggregate_by_day(session_history) + if len(days) >= 3: + recent = days[-3:] + scores = [] + for d in recent: + t = d.get('upshifts', 0) + d.get('downshifts', 0) + g = d.get('upshifts_good', 0) + d.get('downshifts_good', 0) + scores.append(int(g / t * 100) if t > 0 else 100) + avg = sum(scores) / len(scores) + if avg >= 85: + comments['shift_chart'] = random.choice([ + "Recent shifts looking clean!", + "Shift scores are up. Waddle energy.", + "Consistency is showing. Kirby is pleased.", + ]), GREEN + elif scores[-1] > scores[0]: + comments['shift_chart'] = random.choice([ + "Trending up! Keep this momentum.", + "Scores climbing. Weixing might notice soon.", + "Getting better day by day.", + ]), YELLOW + else: + comments['shift_chart'] = random.choice([ + "Shift scores dipping. Focus up!", + "Weixing is watching these numbers drop.", + "Time to tighten up those shifts.", + ]), RED + + # Stalls chart + if len(days) >= 3: + recent_stalls = [d.get('stalls', 0) for d in days[-3:]] + if all(s == 0 for s in recent_stalls): + comments['stalls_chart'] = random.choice([ + "Stall-free streak! Don't break it.", + "Zero stalls lately. Weixing is watching... approvingly.", + "Clean streak. Kirby is relaxed.", + ]), GREEN + elif recent_stalls[-1] < recent_stalls[0]: + comments['stalls_chart'] = random.choice([ + "Stalls trending down. Shedding jackets!", + "Fewer stalls recently. Progress!", + "The jacket count is dropping. Keep going.", + ]), YELLOW + elif recent_stalls[-1] > recent_stalls[0]: + comments['stalls_chart'] = random.choice([ + "Stalls creeping up. Deep breath, find the bite point.", + "More stalls lately. Weixing noticed.", + "Jacket count rising. Kirby is concerned.", + ]), RED + + # Launch chart + if len(days) >= 3: + recent_launch_pcts = [] + for d in days[-3:]: + l_total = d.get('launches', 0) + l_good = d.get('launches_good', 0) + if l_total > 0: + recent_launch_pcts.append(l_good / l_total * 100) + if len(recent_launch_pcts) >= 2: + if all(p >= 80 for p in recent_launch_pcts): + comments['launch_chart'] = random.choice([ + "Launches looking consistent!", + "Smooth off the line, day after day.", + "Kirby trusts your launches now.", + ]), GREEN + elif recent_launch_pcts[-1] > recent_launch_pcts[0]: + comments['launch_chart'] = random.choice([ + "Launch success trending up!", + "Getting smoother off the line.", + "Clutch control improving. Waddle incoming.", + ]), YELLOW + elif recent_launch_pcts[-1] < recent_launch_pcts[0]: + comments['launch_chart'] = random.choice([ + "Launches getting rougher. Easy on the clutch!", + "Launch success dipping. Find that bite point.", + "Kirby is bracing again.", + ]), RED + + return comments + def _get_encouragement(self) -> str: """Get encouragement based on overall progress""" total_drives = self._stats.get('total_drives', 0)